From 3b86674d1b1d18216d2fbd9a7acf0bcfbb15d30b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 18:28:04 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(schedule):=20=E8=AE=93=E6=8E=92?= =?UTF-8?q?=E7=A8=8B=E9=82=A3=E4=B8=80=E8=BC=AA=E8=87=AA=E5=B7=B1=E5=B8=B6?= =?UTF-8?q?=E9=BD=8A=E7=92=B0=E5=A2=83=E8=88=87=E6=AC=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排程條目改成自己帶齊執行環境,install 完也印出那一輪要開的權限規則。 背景助理在無人值守的輪次連續失敗,查出四道關卡。cron 給的 PATH 很短, 不含使用者的 bin 目錄,裸的指令名每一輪都是找不到執行檔。cron 也不讀 殼的設定檔,那一輪沒有 Gitea 主機位址與 wiki 存放庫變數,連不上 wiki。 寫入確認只認 tty,排程沒有 tty,寫監控頁一定被擋。工具權限沒開,巡檢 腳本連跑都跑不起來,而那一輪沒有人可以按同意。四關任何一關沒過,那一 輪就不寫心跳,排程裝著卻永遠空轉,外面只看得到心跳過期。 CLI 改用 command -v 解成絕對路徑再寫進條目,解不到就停下,不猜路徑。 install 當下把主機位址、金鑰、狀態根目錄、心跳門檻與所有已設定的 wiki 存放庫變數就地快照,以名稱等於值的前綴寫進條目,值裡的單引號照 POSIX 寫法跳脫。條目自帶寫入確認旗標,無人值守的那一輪本來就沒有人可以按同 意。install 完印出四支腳本各三種呼叫形式的 allow 規則,路徑一律取 current 那一組不帶版本的連結,並順手檢查連結在不在,不在只警告、不代 建。腳本內部找心跳腳本也改成 current 優先,避免同一輪跑到混版的工具。 條目裡帶著金鑰快照,所以印條目時一律把金鑰遮成星號,遮到下一個空白為 止;crontab 檔案要保持只有本人讀得到,這幾個變數改過就要重跑一次 install。功能範圍是助理的系統排程安裝、移除與查現況。 --- tools/schedule.sh | 200 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 171 insertions(+), 29 deletions(-) diff --git a/tools/schedule.sh b/tools/schedule.sh index 9e78b91..c7ee6cc 100755 --- a/tools/schedule.sh +++ b/tools/schedule.sh @@ -60,6 +60,36 @@ # 條目一律接 `&2; } -# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序比照 jsc-hooks lib.sh 的 -# jsc_gitea_sh():先環境變數,再開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。 +# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序:先環境變數覆寫,再 +# $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。 +# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑另一個 +# 版本,同一輪就會跑到混版的工具,而那種不一致查起來沒有任何線索。 heartbeat_sh() { if [ -n "${JSC_HOOKS_HOOKS:-}" ] && [ -f "$JSC_HOOKS_HOOKS/heartbeat.sh" ]; then printf '%s\n' "$JSC_HOOKS_HOOKS/heartbeat.sh"; return 0 fi + if [ -f "$CURRENT/jsc-hooks/hooks/heartbeat.sh" ]; then + printf '%s\n' "$CURRENT/jsc-hooks/hooks/heartbeat.sh"; return 0 + fi _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" for _c in "$_root/../hooks/hooks/heartbeat.sh" "$_root/../jsc-hooks/hooks/heartbeat.sh"; do [ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/heartbeat.sh\n' "$(pwd)"); return 0; } @@ -206,28 +245,74 @@ spec_of() { } # 巡檢要跑的指令。優先序:--patrol-cmd 或 JSC_ASSIST_PATROL_CMD > 依 CLI 代號推斷。 -# 判不出 CLI 就回非 0,由主流程回 6,不猜——猜錯會每 15 分鐘跑一支不存在的執行檔。 +# 判不出 CLI,或那一支的執行檔不在 PATH 上,都回非 0 由主流程回 6,不猜。 +# 執行檔一律用 `command -v` 解成絕對路徑:cron 的 PATH 只有 /usr/bin 與 /bin,裸的指令名 +# 每一輪都是 not found,而那一輪不會有人看到錯誤訊息。 patrol_command() { [ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; } _cli="$CLI" [ -n "$_cli" ] || _cli="${JSC_CLI:-}" [ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; } case "$_cli" in - claude) printf 'claude -p "/jsc-assist:assistant 跑一輪巡檢"' ;; - codex) printf "codex exec '\$assistant 跑一輪巡檢'" ;; - copilot) printf 'copilot -p "跑一輪助理巡檢"' ;; - antigravity) printf 'agy -p "/jsc-assist:assistant 跑一輪巡檢"' ;; - kiro) printf 'kiro-cli -p "跑一輪助理巡檢"' ;; - *) return 1 ;; + claude) _bin='claude'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;; + codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢'" ;; + copilot) _bin='copilot'; _args='-p "跑一輪助理巡檢"' ;; + antigravity) _bin='agy'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;; + kiro) _bin='kiro-cli'; _args='-p "跑一輪助理巡檢"' ;; + *) printf '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。\n' >&2 + return 1 ;; esac + _abs=$(command -v "$_bin" 2>/dev/null) || _abs='' + # 內建指令與別名也會被 command -v 認出來,但那些寫進 cron 沒有意義,只收絕對路徑。 + case "$_abs" in + /*) ;; + *) printf '這台機器的 PATH 上找不到 %s 的執行檔,排程條目沒有絕對路徑可以寫。請先確認 %s 裝好了,或改用 --patrol-cmd 指定完整指令。\n' "$_cli" "$_bin" >&2 + return 1 ;; + esac + printf "'%s' %s" "$_abs" "$_args" } +# 要快照進條目的環境變數名稱。前四支是巡檢那一輪一定要用到的;JSC_WIKI_REPO 系列逐台機器 +# 不同,直接從現在的環境撈出所有已設定的,不寫死清單。 +snapshot_names() { + printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL + env 2>/dev/null \ + | sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \ + | sort -u +} + +# 把值包成單引號字串。值裡的單引號照 POSIX 的 '\'' 寫法拆開再接回來,不然一個引號就把 +# 整條 cron 指令切斷。 +shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + +# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值—— +# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。 +# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁 +# 一定寫不成,而頁寫不成就不寫心跳,排程等於永遠空轉。 +env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值) + _p='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes' + _names='' + for _n in $(snapshot_names); do + eval "_v=\${$_n:-}" + [ -n "$_v" ] || continue + _p="$_p $_n=$(shq "$_v")" + if [ -z "$_names" ]; then _names="$_n"; else _names="$_names,$_n"; fi + done + [ -n "${1:-}" ] && printf '%s' "$_names" >"$1" + printf '%s' "$_p" +} + +# 條目裡有金鑰快照,原樣印出來就是把金鑰留在對話紀錄與 log 裡,所以印之前先遮掉值。 +# 遮到下一個空白為止,不遮到下一個單引號:值裡的單引號會跳脫成 '\'',遇到引號就收手會把 +# 金鑰的後半段漏出來。Gitea 的金鑰不含空白,所以以空白為界是安全的。 +mask_secret() { sed "s/GITEA_TOKEN=[^ ]*/GITEA_TOKEN='***'/g"; } + # 組出一筆 crontab 條目。`%` 在 crontab 是換行符號,一律跳脫。 cron_entry() { # $1=工作代號 _spec=$(spec_of "$1") case "$1" in heartbeat) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule '$HEARTBEAT' write" ;; - patrol) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule $PATROL_RESOLVED" ;; + patrol) _cmd="$ENV_PREFIX $PATROL_RESOLVED" ;; esac printf '%s %s >%s 2>&1 %s' \ "$_spec" "$_cmd" "'$LOG'" "$(marker_of "$1")" | sed 's/%/\\%/g' @@ -301,18 +386,69 @@ else PERIOD=$(period_for_ttl "$TTL") fi -# 巡檢指令在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡跑, -# 判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。 -PATROL_RESOLVED='' -case "$CMD:$JOBS" in - install:*patrol*) - PATROL_RESOLVED=$(patrol_command) \ - || die 6 '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。' ;; -esac - TMPD=$(mktemp -d) || die 4 '建不出暫存目錄。' trap 'rm -rf "$TMPD"' EXIT +# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡 +# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。 +PATROL_RESOLVED='' +ENV_PREFIX='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes' +SNAPSHOT_NAMES='' +case "$CMD:$JOBS" in + install:*patrol*) + if ! PATROL_RESOLVED=$(patrol_command 2>"$TMPD/cmd.err"); then + _why=$(tr '\n' ' ' <"$TMPD/cmd.err" 2>/dev/null) + [ -n "$_why" ] || _why='判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。' + die 6 "$_why" + fi + ENV_PREFIX=$(env_prefix "$TMPD/snapnames") + SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null) ;; +esac + +# --- 裝完要開的權限 --- + +# 排程那一輪跑在沒有人的工作階段:跳出一次權限詢問就是整輪卡住,卡到鎖逾時才有下一輪。 +# 這一輪會用到的三支腳本,各印裸路徑、`sh 路徑`、`bash 路徑` 三種呼叫形式——同一支腳本換 +# 一種叫法就是另一條規則,少印一種就會在那一種叫法上卡住。 +# 路徑一律是 $JSC_HOME/current/{外掛名} 那一組,不是這支腳本現在被放在哪裡:規則放行的是 +# 那一組路徑,巡檢那一輪也只能用那一組路徑去叫工具,兩邊對得起來才不會被靜靜擋掉。 +print_allow_rules() { + # gitea.sh 一定要有自己這一條。`Skill(jsc-gitea:wiki)` 只放行「叫用那支技能」,技能裡的 + # 每一個 Bash 呼叫仍然各自受檢,少了這一條,那一輪會在寫監控頁時靜靜被擋——頁寫不成就 + # 不寫心跳,外面只看得到心跳過期,看不出是權限擋的。 + for _s in "$CURRENT/jsc-assist/tools/schedule.sh" \ + "$CURRENT/jsc-assist/tools/patrol.sh" \ + "$CURRENT/jsc-hooks/hooks/heartbeat.sh" \ + "$CURRENT/jsc-gitea/tools/gitea.sh"; do + printf 'allow_rule=Bash(%s:*)\n' "$_s" + printf 'allow_rule=Bash(sh %s:*)\n' "$_s" + printf 'allow_rule=Bash(bash %s:*)\n' "$_s" + done + # 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名 + # 等於這條規則永遠比對不到。 + printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR" + printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR" + printf 'allow_rule=Skill(jsc-gitea:wiki)\n' +} + +# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查 +# 與警告,不代建:連結是 deploy 的職責,兩個地方都在建同一組連結,壞掉時查不出是誰建的。 +check_current_links() { + _miss=''; _paths='' + for _p in jsc-assist jsc-gitea; do + [ -e "$CURRENT/$_p" ] && continue + if [ -z "$_miss" ]; then _miss="$_p"; _paths="$CURRENT/$_p" + else _miss="$_miss,$_p"; _paths="$_paths、$CURRENT/$_p"; fi + done + if [ -n "$_miss" ]; then + printf 'current_links=missing:%s\n' "$_miss" + note "找不到這幾個連結:$_paths。巡檢那一輪會照這一組路徑叫工具,連結不在就叫不到,那一輪一定失敗,也不會寫心跳。請先跑 deploy 把連結建起來——本腳本不代建。" + return 0 + fi + printf 'current_links=ok\n' + return 0 +} + # --- schtasks(Windows)--- schtasks_install() { @@ -397,12 +533,12 @@ crontab_install() { if [ "$DRYRUN" -eq 1 ]; then for _job in $JOBS; do - printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job")" + printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)" done - printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s\n' \ - "$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" + printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s\n' \ + "$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}" printf -- '--- 寫回後的 crontab ---\n' - cat "$_new" + mask_secret <"$_new" return 0 fi @@ -422,10 +558,10 @@ crontab_install() { || die 5 "別人的排程條目從 $_others 筆變成 $_kept 筆,寫回不完整。" for _job in $JOBS; do - printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job")" + printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job" | mask_secret)" done - printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s log=%s\n' \ - "$TTL" "$PERIOD" "$_legacy" "$_kept" "$LOG" + printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s log=%s\n' \ + "$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$LOG" return 0 } @@ -454,7 +590,7 @@ crontab_remove() { if [ "$DRYRUN" -eq 1 ]; then printf 'dryrun=crontab action=remove jobs=%s removed=%s\n' "$JOBS" "$_hit" printf -- '--- 寫回後的 crontab ---\n' - cat "$_new" + mask_secret <"$_new" return 0 fi @@ -480,7 +616,7 @@ crontab_status() { printf 'mechanism=crontab service=%s ttl=%s period=%s log=%s\n' \ "$(service_state)" "$TTL" "$PERIOD" "$LOG" for _job in heartbeat patrol; do - _line=$(cron_lines_for "$_cur" "$_job") + _line=$(cron_lines_for "$_cur" "$_job" | mask_secret) if [ -n "$_line" ]; then printf 'job=%s installed=yes entry=%s\n' "$_job" "$_line" else @@ -498,6 +634,12 @@ case "$CMD" in crontab) crontab_install ;; schtasks) schtasks_install ;; esac + # 權限規則與快照提醒排在服務檢查前面:結束碼 1 那一條也是條目已經寫進去了,那台機器 + # 一樣要開權限,只是還要先把 cron 服務叫起來。 + print_allow_rules + check_current_links + note '上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則放行的是 $JSC_HOME/current 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。' + note "條目帶著安裝當下的環境變數快照(${SNAPSHOT_NAMES:-無}),其中含 Gitea 金鑰:crontab 檔案請保持只有本人讀得到。這幾個變數改過就要重跑一次 install,條目才會跟著換。" [ "$DRYRUN" -eq 1 ] && exit 0 _svc=$(service_state) if [ "$_svc" = stopped ]; then From 717a562cb77d1fd4c9c2f0e7b9a3f4db538c1a78 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 18:28:04 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(patrol):=20=E7=9B=A3=E6=8E=A7?= =?UTF-8?q?=E9=A0=81=E6=94=B9=E6=88=90=E5=9B=BA=E5=AE=9A=E4=B8=89=E5=A1=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 巡檢產出與監控頁範本改成固定三塊:本頁基本資料、最新一輪、近 24 輪摘要。 舊寫法是一輪附加一節。十五分鐘跑一輪,一天就疊出九十六節,那樣的頁沒 有人讀得完,也就沒有人會發現助理壞在哪一輪。軌跡留著卻沒人看,等於沒 有留。 基本資料建頁時寫一次就不動。最新一輪每輪整塊換掉,只留最新那一輪的完 整內容。摘要表一輪一列,最新的在最上面,超過 24 列就丟掉最舊的那一列, 一列只有四欄:巡檢時間、本輪判定、四項成敗、待人處理筆數,一眼看得出 是從哪一輪開始壞的。collect 的輸出鍵位跟著換,section_file 不再產出, 改印 latest_file、summary_file 與 summary_row_file,另外多印本輪待人處 理的筆數。範本連帶把流程圖與寫入規則改成先讀回舊頁、再三塊重組整頁寫 回,並寫明舊格式的頁第一次重組要怎麼收。 順手把腳本內部找別的 domain 工具的順序改成 current 連結優先,理由跟排 程那支一樣:技能與權限規則都以 current 為準,腳本自己去挑另一個版本就 會跑到混版的工具,而那種不一致查起來沒有線索。功能範圍是一輪巡檢的收 攏與監控頁產出。 --- templates/monitor-page.md | 51 ++++++++++++++--------- tools/patrol.sh | 85 +++++++++++++++++++++++++++++---------- 2 files changed, 97 insertions(+), 39 deletions(-) diff --git a/templates/monitor-page.md b/templates/monitor-page.md index 7d5cf6e..f67211b 100644 --- a/templates/monitor-page.md +++ b/templates/monitor-page.md @@ -1,20 +1,23 @@ # 助理巡檢 — {主機名}/{登入帳號} > 由 `jsc-assist` 維護。這是監控頁 `MONITOR_{HASH}`。 -> 這頁是這台機器的巡檢軌跡:一次巡檢附加一節,節標題帶時間戳,舊的節一個字都不動。 -> 附加是刻意的。助理的寫入是背景行為,覆寫錯了沒人在現場,軌跡被抹掉也看不出斷在哪一輪。 -> 目錄頁 `MONITOR_CONTENTS` 只更新自己那一列,寫入語意與這頁不同,不要混用。 +> 這頁固定三塊:本頁基本資料、最新一輪、近 24 輪摘要。 +> 最新一輪每輪整塊換掉;摘要表一輪一列往上疊,只留 24 列;基本資料建頁時寫一次就不動。 +> 完整內容只留最新一輪,頁面才讀得完;軌跡留在摘要表,看得出是從哪一輪開始壞的。 +> 目錄頁 `MONITOR_CONTENTS` 只更新自己那一列,別台機器的列一個字都不動。 ```mermaid flowchart LR - A[巡檢一輪] --> B[收攏各類結果] - B --> C[附加一節,節標題帶時間戳] - C --> D[既有的節原樣保留] - D --> E[回頭更新 MONITOR_CONTENTS 自己那一列] - E --> F[最後才寫心跳] + A[巡檢一輪] --> B[收攏四項結果] + B --> C[讀回舊頁] + C --> D[換掉最新一輪那一塊] + D --> E[本輪摘要列插到表格最上面,截到 24 列] + E --> F[整頁寫回] + F --> G[更新 MONITOR_CONTENTS 自己那一列] + G --> H[最後才寫心跳] ``` -心跳排在最後一步,不能提前。心跳新鮮的意思就是「上一輪跑到這一步了」:這一節沒寫上來,心跳就不寫,讓它自己過期。那是巡檢在空轉的唯一訊號。 +心跳排在最後一步,不能提前。心跳新鮮的意思就是「上一輪跑到這一步了」:這一輪沒寫上來,心跳就不寫,讓它自己過期。那是巡檢在空轉的唯一訊號。 ## 本頁基本資料 @@ -27,9 +30,11 @@ flowchart LR | 雜湊來源 | `{主機名}/{登入帳號}` | | 狀態檔根目錄 | `$JSC_HOME/assistant/`(`$JSC_HOME` 未設定就退回 `~/.jsc`) | -## 巡檢 {yyyy-MM-dd HH:mm} +## 最新一輪 -一輪巡檢就是這樣一節,最新的一節放在最下面。六個子節固定都寫;某個來源讀不到,就在那個子節寫明是哪個路徑讀不到,不要整節略過。還沒實作的子節也照寫,寫明「這一輪不做這一項」——空表格會被讀成「查過了,沒問題」。 +這一塊每輪整塊換掉,只留最新那一輪的完整內容。再往前的軌跡看下面的摘要表。 + +六個子節固定都寫;某個來源讀不到,就在那個子節寫明是哪個路徑讀不到,不要整節略過。還沒實作的子節也照寫,寫明「這一輪不做這一項」——空表格會被讀成「查過了,沒問題」。 | 項目 | 內容 | | --- | --- | @@ -49,9 +54,9 @@ flowchart LR | session | {工作階段代號} | | pid | {數字}。只給要找行程的人參考,不參與判定 | -這一欄讀到的是**上一輪**巡檢寫的心跳:心跳由巡檢寫,本輪那一次要等這一節寫上來之後才寫。 +這一欄讀到的是**上一輪**巡檢寫的心跳:心跳由巡檢寫,本輪那一次要等這一頁寫成之後才寫。 -心跳的判準只看 `ts` 距現在有沒有超過門檻,預設 300 秒。不看 pid 存活:五支 CLI 與容器裡的行程互相看不到彼此的 pid。閘門的判定留在 hook,助理只維持心跳。心跳新鮮代表上一輪巡檢跑完了,不代表那一輪四項都成功——那要看本節上面的「本輪判定」。 +心跳的判準只看 `ts` 距現在有沒有超過門檻,預設 300 秒。不看 pid 存活:五支 CLI 與容器裡的行程互相看不到彼此的 pid。閘門的判定留在 hook,助理只維持心跳。心跳新鮮代表上一輪巡檢跑完了,不代表那一輪四項都成功——那要看這一塊上面的「本輪判定」。 ### 技能與呼叫鏈使用統計 @@ -111,11 +116,21 @@ flowchart LR | --- | --- | --- | | {一句話講完要處理什麼} | {上面六個子節之一} | {技能名或指令} | +## 近 24 輪摘要 + +一輪一列,最新的在最上面,超過 24 列就丟掉最舊的那一列。 + +| 巡檢時間 | 本輪判定 | 四項成敗 | 待人處理 | +| --- | --- | --- | ---: | +| {yyyy-MM-dd HH:mm} | {正常、警示、異常 三選一} | {成功項數}/{總項數} | {待人處理筆數} | + ## 寫入規則 -- 一次巡檢附加一節,節標題帶時間戳,節名不重複。 -- 既有的節原樣保留,一個字都不改。 -- 禁止整頁覆寫。覆寫等於把這台機器的巡檢軌跡刪掉。 -- 讀不到舊內容就中止,不附加,也不寫入。 -- 附加成功之後,才回頭更新 `MONITOR_CONTENTS` 自己那一列。 +- 讀不到舊頁就中止,不重組,也不寫入。舊頁讀不回來就沒有摘要表可以接下去。 +- 本頁基本資料原樣保留,一個字都不改。 +- 最新一輪整塊換掉,只留這一輪的完整內容。 +- 本輪的摘要列插到摘要表最上面,舊的列往下移,超過 24 列就丟掉最舊的那一列。 +- 三塊重組成一整頁再整頁寫回。除了這三塊,頁上沒有別的東西。 +- 舊格式的頁(一輪一節疊起來的那種)第一次重組時,基本資料留著,那些節收掉,摘要表從本輪這一列開始,並在回報裡說明。 +- 整頁寫成之後,才回頭更新 `MONITOR_CONTENTS` 自己那一列,別台機器的列一個字都不動。 - 兩頁都寫成之後,才寫這一輪的心跳。任一頁沒寫成就不寫心跳,讓它過期。 diff --git a/tools/patrol.sh b/tools/patrol.sh index b8bd4ee..791cfa8 100755 --- a/tools/patrol.sh +++ b/tools/patrol.sh @@ -8,7 +8,7 @@ # # collect 帶了 --out,finish 與 abort 就要帶同一個目錄,不然換不到本輪的用量快照。 # -# collect 讀四項來源、組出監控頁要附加的那一節、把鎖拿在手上。 +# collect 讀四項來源、組出監控頁那三塊、把鎖拿在手上。 # finish 在監控頁寫成功之後才呼叫:寫心跳、換上用量快照、放掉鎖。 # abort 在監控頁沒寫成時呼叫:只放掉鎖,不寫心跳。 # @@ -16,10 +16,10 @@ # 0 collect:四項全部讀到底(含「來源在、沒有資料」);finish:心跳寫好、快照換上、 # 鎖放掉;abort:鎖放掉,本來就沒鎖也算 # 1 collect:部分成功——至少一項失敗,也至少一項有結果。**結果照樣印得出來,呼叫端 -# 照樣要把這一節寫上監控頁**,只是本輪判定要標成警示 +# 照樣要把這一輪寫上監控頁**,只是本輪判定要標成警示 # 2 finish:找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可寫。collect 不會回這 # 一碼——心跳讀不到只是 D-09 這一項失敗,另外三項照跑 -# 3 collect:四項全部失敗,一項資料都沒有。這一節還是要寫上監控頁,本輪判定標成異常 +# 3 collect:四項全部失敗,一項資料都沒有。這一輪還是要寫上監控頁,本輪判定標成異常 # 4 上一輪還在跑,本輪讓開(collect),或鎖已經不在自己手上(finish、abort)。這不是 # 失敗,是刻意讓開:不寫心跳、不寫監控頁,下一輪再來 # 5 檔案系統失敗:鎖建不起來或放不掉、暫存檔寫不進去、快照換不上,或 heartbeat.sh write @@ -43,8 +43,7 @@ # # 一輪巡檢包含一次 CLI 呼叫與兩次 wiki 寫入,跑過一個排程週期是有可能的。所以整輪拿一把 # 鎖:$JSC_HOME/assistant/patrol.lock 是目錄,mkdir 是原子操作,搶不到就是別人在跑。 -# 搶不到的那一輪回 4 直接讓開,不排隊、不並行——並行的兩輪會在同一頁附加兩節,還會互相 -# 蓋掉用量快照。 +# 搶不到的那一輪回 4 直接讓開,不排隊、不並行——並行的兩輪會互相蓋掉監控頁與用量快照。 # 鎖會逾時自動搶回來,門檻取心跳門檻(heartbeat.sh report 的 ttl 欄):上一輪跑得比門檻 # 還久,它本來就已經維持不住心跳新鮮了,讓新的一輪接手才對。被搶回來的那一輪,finish 會 # 拿 --round 比對出鎖不是自己的,回 4 且不寫心跳。 @@ -65,6 +64,16 @@ # 巡檢照抄第四欄原字,不自己補查遠端版本、不把「查詢失敗」寫成「相符」或「最新」。 # 查不到就是沒有證據,寫成別的字等於幫一個既有缺陷蓋章。 # +# --- 監控頁固定三塊 --- +# +# 監控頁不再一輪附加一節。15 分鐘一輪,一天就是 96 節,那樣的頁沒有人讀得完,也就沒有人 +# 會發現壞掉。改成固定三塊: +# 本頁基本資料 建頁時寫一次,之後一個字都不動 +# 最新一輪 每輪整塊換掉,只留最新那一輪的完整內容 +# 近 24 輪摘要 一輪一列,最新的在最上面,超過 24 列就丟掉最舊的 +# 軌跡留在摘要表:一輪一列,看得出是從哪一輪開始壞的。完整內容只留最新一輪,因為頁面要能 +# 讀完才有人讀。 +# # --- collect 的輸出 --- # # stdout 是 key=value,一行一個鍵,供呼叫端逐行取值。監控頁要用的 markdown 不印在 @@ -79,8 +88,11 @@ # verdict= 正常、警示、異常 # failed_sources= 讀不到的來源路徑,以「、」分隔;全部讀得到就是「無」 # tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,只供目錄頁那一列用 -# section_file= 要附加到 MONITOR_{HASH} 的那一節 -# newpage_file= MONITOR_{HASH} 不存在時要建的整頁內容 +# pending= 本輪待人處理的筆數 +# latest_file= 「最新一輪」那一塊,整塊換掉舊頁同名那一塊 +# summary_file= 「近 24 輪摘要」那一塊,表格裡先放本輪這一列,舊頁的資料列接在下面 +# summary_row_file= 只有本輪那一列,方便直接插到既有表格最上面 +# newpage_file= MONITOR_{HASH} 不存在時要建的整頁內容,三塊都已經排好 # contents_file= MONITOR_CONTENTS 那一列的欄位值 # # 環境變數: @@ -95,6 +107,7 @@ set -u JSC_HOME="${JSC_HOME:-$HOME/.jsc}" STATE_DIR="$JSC_HOME/assistant" +CURRENT="$JSC_HOME/current" LOCK="$STATE_DIR/patrol.lock" PREV_SNAP="$STATE_DIR/usage-prev.tsv" RD="$STATE_DIR/patrol" @@ -126,12 +139,17 @@ die() { # $1=結束碼 $2=訊息 } # 找一支別的 domain 的腳本。搜尋順序比照 schedule.sh 的 heartbeat_sh():先環境變數覆寫, -# 再開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。 +# 再 $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取 +# 版面。current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑 +# 另一個版本,同一輪就會跑到混版的工具,而那種不一致查起來沒有任何線索。 find_tool() { # $1=domain 短名 $2=domain 內相對路徑 $3=環境變數覆寫值(可為空) if [ -n "$3" ]; then [ -f "$3" ] && { printf '%s\n' "$3"; return 0; } return 1 fi + for _c in "$CURRENT/jsc-$1/$2" "$CURRENT/$1/$2"; do + [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + done _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" for _c in "$_root/../$1/$2" "$_root/../jsc-$1/$2"; do [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } @@ -519,7 +537,7 @@ d09() { printf '| pid | %s。只給要找行程的人參考,不參與判定 |\n' "$(cell "${_pid:--}")" printf '| 心跳檔 | `%s` |\n' "$(cell "${_file:--}")" printf '\n心跳的判準只看 `ts` 距現在有沒有超過門檻,不看 pid 存活:五支 CLI 與容器裡的行程互相看不到彼此的 pid。閘門的判定留在 hook,助理只維持心跳,不參與判定。\n' - printf '\n心跳新鮮代表上一輪巡檢跑完了,而且結果記上監控頁了。它不代表那一輪四項都成功——四項的成敗看本節上面的「本輪判定」。\n' + printf '\n心跳新鮮代表上一輪巡檢跑完了,而且結果記上監控頁了。它不代表那一輪四項都成功——四項的成敗看這一塊上面的「本輪判定」。\n' } >>"$RD/d09.md" case "$_st" in @@ -546,7 +564,7 @@ count_tasks() { return 0 } -# --- 組出監控頁那一節 --- +# --- 組出監控頁那三塊 --- tally() { # $1=項目狀態 case "$1" in @@ -562,8 +580,12 @@ compose() { [ "$OK_COUNT" -eq 0 ] && _v='異常' VERDICT="$_v" + ITEM_TOTAL=$(( OK_COUNT + FAIL_COUNT )) + PEND_COUNT=$(grep -c '^| ' "$RD/pend.md" 2>/dev/null); [ -n "$PEND_COUNT" ] || PEND_COUNT=0 + { - printf '## 巡檢 %s\n\n' "$AT" + printf '## 最新一輪\n\n' + printf '這一塊每輪整塊換掉,只留最新那一輪的完整內容。再往前的軌跡看下面的摘要表。\n\n' printf '| 項目 | 內容 |\n' printf '| --- | --- |\n' printf '| 巡檢時間 | %s |\n' "$AT" @@ -589,21 +611,38 @@ compose() { printf '| 項目 | 來源子節 | 建議入口 |\n' printf '| --- | --- | --- |\n' if [ -s "$RD/pend.md" ]; then cat "$RD/pend.md"; else printf '| (無) | - | - |\n'; fi - } >"$RD/section.md" + } >"$RD/latest.md" + + # 摘要表的那一列。欄位刻意只有四個:時間、判定、四項成敗、待人處理筆數——一列要能一眼 + # 看完,才看得出是從哪一輪開始壞的。 + printf '| %s | %s | %s/%s | %s |\n' \ + "$AT" "$VERDICT" "$OK_COUNT" "$ITEM_TOTAL" "$PEND_COUNT" >"$RD/summary-row.md" + + # 摘要那一塊:標題、表頭,加上本輪這一列。呼叫端把舊頁的資料列接在這一列下面,截到 24 列。 + { + printf '## 近 24 輪摘要\n\n' + printf '一輪一列,最新的在最上面,超過 24 列就丟掉最舊的那一列。\n\n' + printf '| 巡檢時間 | 本輪判定 | 四項成敗 | 待人處理 |\n' + printf '| --- | --- | --- | ---: |\n' + cat "$RD/summary-row.md" + } >"$RD/summary.md" # 監控頁不存在時要建的整頁內容。基本資料建頁時寫一次,之後不再更動。 { printf '# 助理巡檢 — %s/%s\n\n' "$HOST" "$USER_NAME" printf '> 由 `jsc-assist` 維護。這是監控頁 `%s`。\n' "$PAGE" - printf '> 這頁是這台機器的巡檢軌跡:一次巡檢附加一節,節標題帶時間戳,舊的節一個字都不動。\n' - printf '> 附加是刻意的。助理的寫入是背景行為,覆寫錯了沒人在現場,軌跡被抹掉也看不出斷在哪一輪。\n' - printf '> 目錄頁 `MONITOR_CONTENTS` 只更新自己那一列,寫入語意與這頁不同,不要混用。\n\n' + printf '> 這頁固定三塊:本頁基本資料、最新一輪、近 24 輪摘要。\n' + printf '> 最新一輪每輪整塊換掉;摘要表一輪一列往上疊,只留 24 列;基本資料建頁時寫一次就不動。\n' + printf '> 完整內容只留最新一輪,頁面才讀得完;軌跡留在摘要表,看得出是從哪一輪開始壞的。\n' + printf '> 目錄頁 `MONITOR_CONTENTS` 只更新自己那一列,別台機器的列一個字都不動。\n\n' printf '```mermaid\nflowchart LR\n' printf ' A[巡檢一輪] --> B[收攏四項結果]\n' - printf ' B --> C[附加一節,節標題帶時間戳]\n' - printf ' C --> D[既有的節原樣保留]\n' - printf ' D --> E[回頭更新 MONITOR_CONTENTS 自己那一列]\n' - printf ' E --> F[最後才寫心跳]\n' + printf ' B --> C[讀回舊頁]\n' + printf ' C --> D[換掉最新一輪那一塊]\n' + printf ' D --> E[本輪摘要列插到表格最上面,截到 24 列]\n' + printf ' E --> F[整頁寫回]\n' + printf ' F --> G[更新 MONITOR_CONTENTS 自己那一列]\n' + printf ' G --> H[最後才寫心跳]\n' printf '```\n\n' printf '## 本頁基本資料\n\n' printf '建頁時寫一次,之後不再更動。\n\n' @@ -613,7 +652,8 @@ compose() { printf '| 帳號 | %s |\n' "$(cell "$USER_NAME")" printf '| 雜湊來源 | `%s/%s` |\n' "$(cell "$HOST")" "$(cell "$USER_NAME")" printf '| 狀態檔根目錄 | `$JSC_HOME/assistant/`(`$JSC_HOME` 未設定就退回 `~/.jsc`) |\n\n' - cat "$RD/section.md" + cat "$RD/latest.md"; printf '\n' + cat "$RD/summary.md" } >"$RD/newpage.md" { @@ -695,7 +735,10 @@ case "$CMD" in printf 'failed_sources=%s\n' "${FAILED_SOURCES:-無}" printf 'tasks_total=%s\n' "$TASKS_TOTAL" printf 'tasks_failing=%s\n' "$TASKS_FAILING" - printf 'section_file=%s\n' "$RD/section.md" + printf 'pending=%s\n' "$PEND_COUNT" + printf 'latest_file=%s\n' "$RD/latest.md" + printf 'summary_file=%s\n' "$RD/summary.md" + printf 'summary_row_file=%s\n' "$RD/summary-row.md" printf 'newpage_file=%s\n' "$RD/newpage.md" printf 'contents_file=%s\n' "$RD/contents.tsv" From a8e387ad50d00ad69846666ea4f4c5f8be8868c1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 18:28:04 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(assistant):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=B7=9F=E4=B8=8A=E6=96=B0=E7=9A=84=E5=B7=A5=E5=85=B7=E8=B7=AF?= =?UTF-8?q?=E5=BE=91=E8=88=87=E7=9B=A3=E6=8E=A7=E9=A0=81=E5=AF=AB=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 技能主文、行為清單、專案說明與界線文件一起改,對齊這一輪的排程修正與 監控頁改版。 工具路徑與監控頁寫法都換了,文件沒跟上就是照舊做法跑:用快取基底目錄 組出來的路徑會被權限靜靜擋掉,那一輪停在沒有人能回答的權限詢問;照舊 的附加語意寫頁,又會把剛換好的三塊寫回一輪一節。 技能主文的工具路徑一律改走 current 那一組不帶版本的路徑,新增 Tool paths 一節列出四支工具,並寫明權限閘門只放行那一組,放行技能不等於放 行技能裡的每一個呼叫。監控頁那幾步改寫成讀回舊頁、基本資料原樣留著、 最新一輪整塊換掉、本輪摘要列擺最上面並截到 24 列,舊格式的頁第一次重 組要在回報裡說明。frontmatter 的 description 重寫並補上單引號,句中有 冒號不加引號會讓解析走偏。界線四從「只附加」改成三塊寫入語意。專案說 明與行為清單同步條目的環境快照、allow 規則、暫存檔名與可驗證跡象。 功能範圍是助理技能的文件與行為合約,不動任何腳本行為。 --- AGENTS.md | 2 +- README.md | 10 ++--- references/behaviors.md | 8 ++-- skills/assistant/SKILL.md | 87 +++++++++++++++++++++++++-------------- 4 files changed, 67 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a84dc2c..b45dad0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ 1. 不做任何要問使用者的決策。背景巡檢時靜默套預設值,等於把逐項共識整條做掉。 2. 不參與閘門判定。閘門必須留在 hook:同步、不連網、毫秒級。助理只負責維持心跳。 3. 不寫程式碼存取庫、不 commit、不 push、不開 PR、不合併。 -4. 不覆寫 wiki 頁,一律只附加。助理的寫入是背景行為,覆寫錯了沒人在現場。 +4. 監控頁只照固定三塊寫:最新一輪整塊換掉、摘要表保留近 24 輪、基本資料建頁之後不動;目錄頁只動自己那一列,別台機器的列一個字都不碰。軌跡留在摘要表,一輪一列,看得出是從哪一輪開始壞的;完整內容只留最新一輪,因為頁面要能讀完才有人讀。 5. 不刪除狀態檔、worktree 與 wiki 頁。破壞性操作留給人發動。 6. 不自動執行自己提出的建議。建議與執行是兩件事,自動接下去等於整條流程沒人按過同意就跑完。 diff --git a/README.md b/README.md index 51977cb..047d719 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 ### `assistant` -助理主體,四個操作:`start` 啟動、`status` 查現況、`patrol` 跑一輪巡檢、`stop` 停止。心跳的寫入、判定與清除一律交給 `jsc-hooks` 的 `hooks/heartbeat.sh`,判定只有那一份;系統排程一律交給 `tools/schedule.sh`;一輪巡檢的流程交給 `tools/patrol.sh`。**心跳由巡檢寫,而且只由巡檢寫**:一輪跑完、結果寫上監控頁了,才寫那一次心跳,所以心跳新鮮等於「上一輪巡檢真的做完了」。`start` 先跑一輪巡檢,再裝上巡檢那一筆排程;巡檢週期由心跳的過期門檻算出來,兩個數字綁在一起。`patrol` 讀四項來源(使用統計、版本與重啟閘門、SDLC 階段鎖與工作包鎖、心跳自述),四項各自獨立,一項掛掉其餘三項照跑、照記,結果一律附加到 `MONITOR_{HASH}`、不覆寫。`status` 全程唯讀,讀心跳、排程與待辦簿,印成三塊;助理沒在跑就印「助理未運行」,不當成錯誤。`stop` 先移除排程再清掉心跳,順序不能反。這支不參與閘門判定、不做決策、巡檢那一路全程不問人。 +助理主體,四個操作:`start` 啟動、`status` 查現況、`patrol` 跑一輪巡檢、`stop` 停止。心跳的寫入、判定與清除一律交給 `jsc-hooks` 的 `hooks/heartbeat.sh`,判定只有那一份;系統排程一律交給 `tools/schedule.sh`;一輪巡檢的流程交給 `tools/patrol.sh`。工具一律用 `$JSC_HOME/current/{外掛名}` 那一組不帶版本的路徑叫,不用技能提示給的快取基底目錄——權限只放行 current 那一組。**心跳由巡檢寫,而且只由巡檢寫**:一輪跑完、結果寫上監控頁了,才寫那一次心跳,所以心跳新鮮等於「上一輪巡檢真的做完了」。`start` 先跑一輪巡檢,再裝上巡檢那一筆排程;巡檢週期由心跳的過期門檻算出來,兩個數字綁在一起。`patrol` 讀四項來源(使用統計、版本與重啟閘門、SDLC 階段鎖與工作包鎖、心跳自述),四項各自獨立,一項掛掉其餘三項照跑、照記,結果寫上 `MONITOR_{HASH}`:那頁固定三塊,基本資料不動、最新一輪整塊換掉、摘要表保留近 24 輪,一輪一列。`status` 全程唯讀,讀心跳、排程與待辦簿,印成三塊;助理沒在跑就印「助理未運行」,不當成錯誤。`stop` 先移除排程再清掉心跳,順序不能反。這支不參與閘門判定、不做決策、巡檢那一路全程不問人。 @@ -43,11 +43,11 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | 檔案 | 用途 | | --- | --- | -| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動 | -| `tools/patrol.sh` | 一輪巡檢的收攏與收口。三個子命令:`collect` 取鎖、讀四項來源、組出監控頁要附加的那一節與目錄頁那一列;`finish` 在監控頁寫成之後才寫心跳、換上用量快照、放掉鎖;`abort` 只放掉鎖,不寫心跳。四項來源各自獨立,一項失敗其餘三項照跑,失敗那一項在頁上寫明是「這一項失敗」而不是沒資料。整輪拿一把目錄鎖,上一輪還在跑就回 4 讓開;鎖逾時(門檻取心跳門檻)會被下一輪搶回來,並在頁上記一筆。`version-guard.sh report` 回「查詢失敗」時照原字抄,不補查、不美化 | +| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動 | +| `tools/patrol.sh` | 一輪巡檢的收攏與收口。三個子命令:`collect` 取鎖、讀四項來源、組出監控頁的「最新一輪」與「近 24 輪摘要」兩塊、本輪的摘要列與目錄頁那一列;`finish` 在監控頁寫成之後才寫心跳、換上用量快照、放掉鎖;`abort` 只放掉鎖,不寫心跳。四項來源各自獨立,一項失敗其餘三項照跑,失敗那一項在頁上寫明是「這一項失敗」而不是沒資料。整輪拿一把目錄鎖,上一輪還在跑就回 4 讓開;鎖逾時(門檻取心跳門檻)會被下一輪搶回來,並在頁上記一筆。`version-guard.sh report` 回「查詢失敗」時照原字抄,不補查、不美化 | | `references/behaviors.md` | 本 domain 的技能行為清單:一支技能一節,五列記下觸發時機、關鍵步驟、外部呼叫、完成條件、可驗證跡象,供稽核與驗證比對。格式合約見 `plugins/meta` 的 `references/guidelines.md`「技能行為清單」 | | `templates/monitor-contents.md` | 目錄頁 `MONITOR_CONTENTS` 的範本。一列代表一台機器,雜湊來源是 `{主機名}/{登入帳號}`。寫入語意是**只更新自己那一列**:比對主機與帳號兩欄,別台機器的列原樣保留,禁止整頁覆蓋 | -| `templates/monitor-page.md` | 內容頁 `MONITOR_{HASH}` 的範本。記的是這台機器的巡檢軌跡。寫入語意與目錄頁相反,是**一律附加一節、不覆寫**:一次巡檢一節,節標題帶時間戳,既有的節一個字都不動 | +| `templates/monitor-page.md` | 內容頁 `MONITOR_{HASH}` 的範本。記的是這台機器的巡檢軌跡。頁面固定三塊:本頁基本資料建頁時寫一次就不動、最新一輪每輪整塊換掉、近 24 輪摘要一輪一列且最新的在最上面。軌跡留在摘要表,完整內容只留最新一輪,頁面才讀得完 | ## 助理的狀態檔 @@ -59,7 +59,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | `tasks/{id}` | 待辦簿,一筆一檔。一筆一檔是為了讓並行寫入不互相覆寫 | | `schedule.log` | 排程條目的輸出。刻意放在存取庫外面:寫進專案會多出未追蹤檔,污染別人的變更盤點 | | `patrol.lock/` | 一輪巡檢的鎖,是目錄——`mkdir` 是原子操作,搶不到就是別人在跑。裡面的 `info` 記 `round`、`pid`、`started` | -| `patrol/` | 本輪巡檢的暫存檔:`section.md` 是要附加的那一節,`newpage.md` 是頁不存在時要建的整頁,`contents.tsv` 是目錄頁那一列 | +| `patrol/` | 本輪巡檢的暫存檔:`latest.md` 是「最新一輪」那一塊,`summary.md` 是摘要那一塊、裡面已經放好本輪這一列,`summary-row.md` 只有那一列,`newpage.md` 是頁不存在時要建的整頁,`contents.tsv` 是目錄頁那一列 | | `usage-prev.tsv` | 上一輪記下來的累計用量。有了它,下一輪的「本輪次數」才算得出來;沒有它的第一輪一律寫「-」,不拿累計冒充本輪 | ## 相關 domain diff --git a/references/behaviors.md b/references/behaviors.md index 510eb64..65d6b93 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -7,7 +7,7 @@ | 項目 | 內容 | | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。執行環境健檢不走這支,走 `jsc-cli:doctor`。技能使用次數不走這支,走 `jsc-log:stats` | -| 關鍵步驟 | 先認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀四項來源、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把那一節寫上監控頁、`hash` 是空的就 `abort`、把 `section_file` 交給 `jsc-gitea:wiki` 附加到 `MONITOR_{HASH}`、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、把 `contents_file` 的 `row` 更新到 `MONITOR_CONTENTS` 自己那一列、兩次寫入任一失敗就 `abort` 且不寫心跳、全部寫成才跑 `tools/patrol.sh finish` 寫心跳、最後印出四項結果與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人 | -| 外部呼叫 | `jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令,七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat` 與週期塞不進門檻。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort` 三個子命令,七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是四項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那四項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`,全部只讀,任一項失敗不影響其餘三項。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | -| 完成條件 | `start` 要那一輪巡檢的 `finish` 回 0 且 `report` 回 `state=fresh`,才算啟動成功;巡檢沒寫成心跳一律回報失敗並停下,不得宣稱啟動;`schedule.sh install patrol` 回 1 要講明條目不會被執行與 `sudo service cron start`,不得宣稱排程會定時執行。`patrol` 要四項各自有 `status`、監控頁附加成功、目錄頁那一列更新成功、`finish` 回 0,才算一輪跑完;`collect` 回 4 是讓開,不算失敗也不寫任何東西;監控頁或目錄頁任一沒寫成就 `abort`,回報「這一輪沒有結果」,心跳一定不寫。`status` 要印出現況表,或印出「助理未運行」並說明原因;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝,四種都算正常結束。`stop` 要 `schedule.sh remove all` 先回 0、`clear` 再回 0,並印出帶三段話的停止訊息;`remove` 非 0 就回報排程還在、助理停不掉,不清心跳也不印停止訊息;`clear` 回 5 就回報心跳檔還在、助理沒有確實停掉,不印停止訊息 | -| 可驗證跡象 | `start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 多一節、節標題帶時間戳、舊的節一字不改,`MONITOR_CONTENTS` 只有自己那一列變動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `section.md`、`newpage.md`、`contents.tsv`,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/patrol.lock` 已經放掉。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作都不動 `tasks/` 底下的檔案,也不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | +| 關鍵步驟 | 先認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀四項來源、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把這一輪寫上監控頁、`hash` 是空的就 `abort`、經 `jsc-gitea:wiki` 讀回 `MONITOR_{HASH}` 舊頁、基本資料原樣留著、最新一輪那一塊整塊換成 `latest_file`、`summary_file` 的本輪那一列擺最上面、舊的資料列接在下面並截到 24 列、三塊重組成整頁寫回、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、讀不回舊頁就不寫、把 `contents_file` 的 `row` 更新到 `MONITOR_CONTENTS` 自己那一列、兩次寫入任一失敗就 `abort` 且不寫心跳、全部寫成才跑 `tools/patrol.sh finish` 寫心跳、最後印出四項結果與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人 | +| 外部呼叫 | 工具一律走 `$JSC_HOME/current/{外掛名}` 那一組不帶版本的路徑:`current/jsc-assist/tools/patrol.sh`、`current/jsc-assist/tools/schedule.sh`、`current/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `current/jsc-gitea/tools/gitea.sh`,`$JSC_HOME` 沒設就退回 `~/.jsc`;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會查 `$JSC_HOME/current/jsc-assist` 與 `$JSC_HOME/current/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目、條目自帶 `JSC_GITEA_CONFIRM=yes`、並印出這一輪要開的 `allow_rule=` 規則(四支腳本各三種呼叫形式,含 `gitea.sh`——`Skill(jsc-gitea:wiki)` 只放行叫用技能,技能內部的 Bash 呼叫仍各自受檢;路徑是 `current` 那一組確切路徑,不用萬用字元);七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat`、週期塞不進門檻、判不出 CLI,以及那一支 CLI 的執行檔不在 `PATH` 上。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort` 三個子命令,七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是四項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那四項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`,全部只讀,任一項失敗不影響其餘三項。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | +| 完成條件 | `start` 要那一輪巡檢的 `finish` 回 0 且 `report` 回 `state=fresh`,才算啟動成功;巡檢沒寫成心跳一律回報失敗並停下,不得宣稱啟動;`schedule.sh install patrol` 回 1 要講明條目不會被執行與 `sudo service cron start`,不得宣稱排程會定時執行;回 0 或 1 都要把 `allow_rule=` 各行、「條目含金鑰快照、變數改了要重裝」這句提醒,以及 `current` 連結缺漏的警告轉出去。`patrol` 要四項各自有 `status`、監控頁三塊重組寫成、目錄頁那一列更新成功、`finish` 回 0,才算一輪跑完;`collect` 回 4 是讓開,不算失敗也不寫任何東西;舊頁讀不回來就不寫,回報「這一輪沒有結果」;監控頁或目錄頁任一沒寫成就 `abort`,心跳一定不寫。`status` 要印出現況表,或印出「助理未運行」並說明原因;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝,四種都算正常結束。`stop` 要 `schedule.sh remove all` 先回 0、`clear` 再回 0,並印出帶三段話的停止訊息;`remove` 非 0 就回報排程還在、助理停不掉,不清心跳也不印停止訊息;`clear` 回 5 就回報心跳檔還在、助理沒有確實停掉,不印停止訊息 | +| 可驗證跡象 | `start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照;install 印出的 `allow_rule=` 都是 `$JSC_HOME/current` 那一組確切路徑,沒有萬用字元,也沒有 `Write(...)`。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 只有三塊:基本資料一字未改、最新一輪換成本輪、摘要表最上面一列是本輪且總列數不超過 24,`MONITOR_CONTENTS` 只有自己那一列變動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `latest.md`、`summary.md`、`summary-row.md`、`newpage.md`、`contents.tsv`,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/patrol.lock` 已經放掉。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作都不動 `tasks/` 底下的檔案,也不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | diff --git a/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index e58dc92..8683fcc 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -1,20 +1,37 @@ --- name: assistant -description: Start, inspect, patrol, or stop the background assistant, with jsc-hooks/hooks/heartbeat.sh owning the single freshness verdict, tools/schedule.sh owning the system scheduler, and tools/patrol.sh owning one patrol round. The heartbeat is written by a completed patrol round and by nothing else, so the schedule carries the patrol entry only and its period is derived from the heartbeat TTL; start runs one round and then installs that entry, status turns heartbeat.sh report, schedule.sh status and the task book into one read-only table, stop removes the entry first and then clears the heartbeat. One round reads four independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, and the heartbeat's own report - and appends the result to wiki MONITOR_{HASH} through jsc-gitea:wiki before tools/patrol.sh finish writes the heartbeat. A round that cannot record its result writes no heartbeat, and a round that starts while the previous one still holds the lock stands down. Use when someone starts, patrols or stops the assistant, or asks whether it is running and what is queued; not for environment health checks (jsc-cli:doctor), not for skill usage counts (jsc-log:stats). +description: 'Start, inspect, patrol or stop the background assistant: jsc-hooks/hooks/heartbeat.sh owns the freshness verdict, tools/schedule.sh the system scheduler, tools/patrol.sh one round. The heartbeat is written by a completed round and by nothing else, so the schedule carries the patrol entry only, its period from the heartbeat TTL; start runs one round then installs that entry - absolute CLI path, environment snapshot, unattended write confirmation, which cron lacks - status prints heartbeat, schedule and task book read-only, stop removes the entry before clearing the heartbeat. One round reads four independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, and the heartbeat''s own report - then rewrites wiki MONITOR_{HASH} through jsc-gitea:wiki as three fixed blocks: basic data untouched, the latest round replaced whole, a 24-row summary table. A round that cannot record its result writes no heartbeat; one that starts while the previous holds the lock stands down. Use when someone starts, patrols or stops the assistant, or asks whether it runs and what is queued; not for environment health checks (jsc-cli:doctor), not for skill usage counts (jsc-log:stats).' --- # assistant — start, status, patrol, stop The background assistant runs where nobody is watching it. Its heartbeat is the only evidence that it is alive, so this skill is the single entry point for the four operations that touch that evidence: `patrol` writes it, `status` reads it, `stop` clears it, and `start` bootstraps the whole loop. -`jsc-hooks/hooks/heartbeat.sh` owns every heartbeat operation, including the freshness verdict. Never read, parse, write or delete `$JSC_HOME/assistant/heartbeat` directly — one verdict, one source. +`$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh` owns every heartbeat operation, including the freshness verdict. Never read, parse, write or delete `$JSC_HOME/assistant/heartbeat` directly — one verdict, one source. -`tools/schedule.sh` owns every system-scheduler operation: installing an entry, removing it, and reading which entries exist. Never call `crontab` or `schtasks` from this skill, and never edit a crontab by hand. +`$JSC_HOME/current/jsc-assist/tools/schedule.sh` owns every system-scheduler operation: installing an entry, removing it, and reading which entries exist. Never call `crontab` or `schtasks` from this skill, and never edit a crontab by hand. -`tools/patrol.sh` owns one patrol round: taking the round lock, reading the four sources, composing the monitor-page section, and — after that section is on the page — writing the heartbeat. Never re-read a source this skill already handed to that script, and never compose the section by hand; the script prints the file paths. +`$JSC_HOME/current/jsc-assist/tools/patrol.sh` owns one patrol round: taking the round lock, reading the four sources, composing the monitor page's blocks, and — after the page carries this round — writing the heartbeat. Never re-read a source this skill already handed to that script, and never compose a block by hand; the script prints the file paths. All three flows have fixed inputs and outputs, so all three live in scripts. The task book is the only thing this skill reads for itself, and that is one directory listing. +## Tool paths + +Every tool below is addressed through `$JSC_HOME/current/{plugin}`, and `$JSC_HOME` falls back to `~/.jsc` exactly as everywhere else in this skill: + +| What it does | Path to run | +| --- | --- | +| one patrol round | `$JSC_HOME/current/jsc-assist/tools/patrol.sh` | +| the system scheduler | `$JSC_HOME/current/jsc-assist/tools/schedule.sh` | +| the heartbeat | `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh` | +| the wiki, through `jsc-gitea:wiki` | `$JSC_HOME/current/jsc-gitea/tools/gitea.sh` | + +**A `Skill(...)` rule permits invoking that skill and nothing more.** Every Bash call inside it is still checked on its own, so `jsc-gitea:wiki` reaching the wiki depends on `gitea.sh` carrying its own rule — without it the round is refused locally, before any request leaves the machine, and the monitor page never gets written. + +**Never build a tool path out of the base directory the CLI hands you in the skill prompt.** That directory points into the plugin cache and carries a version segment, and the permission gate allows exactly the four paths above and nothing else. A cache path is therefore refused silently: the round stops on a permission prompt nobody can answer, records nothing, writes no heartbeat, and the refusal looks exactly like a broken tool. Read the paths off this table every time — not off the prompt, not off a previous transcript, not off `crontab -l`. + +`current` is a set of version-free links that `jsc-cli:deploy` maintains, so an upgrade moves the cache and leaves these paths alone. When one of them is missing, report the missing link and say `jsc-cli:deploy` has to run; never fall back to a cache path to get the round through, and never create the link here. + ## Pick the operation Run exactly one operation per invocation. Take it from the request: starting, launching or waking the assistant is `start`; asking whether it runs, what it is doing, or what is queued is `status`; running one round, patrolling, or a scheduled wake-up is `patrol`; stopping, halting or shutting it down is `stop`. When the request names none of the four, or names more than one, ask through the `jsc-ask:ask` decision tree with those four as the options, each stating its effect — `start` runs one round and installs the scheduled entry that keeps running rounds, `status` changes nothing, `patrol` runs one round and writes one heartbeat, `stop` removes that entry and deletes the heartbeat. **The one exception: a `patrol` invocation never asks anything at all** (see 界線 1 below). Never guess, and never run a second operation the caller did not ask for. Completion condition: exactly one of `start`, `status`, `patrol`, `stop` is chosen and named in the report. @@ -27,7 +44,7 @@ Run exactly one operation per invocation. Take it from the request: starting, la | `$JSC_HOME/assistant/schedule.log` | nobody here — the scheduled entry appends to it | free text; point the operator at it when a scheduled round misbehaves | | `$JSC_HOME/assistant/tasks/{id}` | this skill, read-only | `key=value` lines, one task per file: `id`, `kind` (`check` / `todo`), `title`, `action`, `trigger`, `recur`, `repo`, `due`, `state` (`pending` / `done` / `paused`), `last_run`, `next_run`, `fail_count`, `origin` (`user` / `assistant`) | | `$JSC_HOME/assistant/patrol.lock/` | `patrol.sh` only | the round lock, a directory. `info` holds `round`, `pid`, `started` | -| `$JSC_HOME/assistant/patrol/` | `patrol.sh` only | one round's scratch files, including `section.md`, `newpage.md` and `contents.tsv` | +| `$JSC_HOME/assistant/patrol/` | `patrol.sh` only | one round's scratch files, including `latest.md`, `summary.md`, `summary-row.md`, `newpage.md` and `contents.tsv` | | `$JSC_HOME/assistant/usage-prev.tsv` | `patrol.sh` only | last recorded round's cumulative usage counts, so the next round can print a real per-round delta | `$JSC_HOME` defaults to `~/.jsc`. `heartbeat.sh report` prints the resolved heartbeat path in its `file=` field, so take the assistant directory from there rather than rebuilding it. @@ -50,7 +67,7 @@ Every call in every operation below is judged by this table. Report the code you ## The scheduler -Nothing in a background assistant runs on its own. The system scheduler is what makes it periodic, and `tools/schedule.sh` is the only thing here that touches it. One job exists, written as exactly one entry carrying the fixed marker `# jsc-assist:assistant patrol`: +Nothing in a background assistant runs on its own. The system scheduler is what makes it periodic, and `$JSC_HOME/current/jsc-assist/tools/schedule.sh` is the only thing here that touches it. One job exists, written as exactly one entry carrying the fixed marker `# jsc-assist:assistant patrol`: | Job | Period | Runs | Installed by `start` | | --- | --- | --- | --- | @@ -69,10 +86,12 @@ Four properties of that script matter enough to state here, because a report tha - **A written entry is not a running entry.** WSL does not start cron by default, and this is the machine's most likely state. Exit 1 from `install` means the entry is on disk and will never fire. Report that as a failure of the start, name `sudo service cron start`, and say it has to be run again after every WSL restart. Never soften exit 1 into "scheduling is set up". - **The log lives at `$JSC_HOME/assistant/schedule.log`**, deliberately outside every repository. Do not offer to move it into a project. - **The entry runs with no human present.** The command is installed with ` Date: Tue, 1 Sep 2026 18:28:04 +0800 Subject: [PATCH 4/5] =?UTF-8?q?chore(manifest):=20=E4=B8=89=E4=BB=BD?= =?UTF-8?q?=E5=A4=96=E6=8E=9B=E6=B8=85=E5=96=AE=E5=8D=87=E5=88=B0=200.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三份外掛清單的版本從 0.1.0 改成 0.1.1。 這一輪改了排程條目的組法、監控頁的格式與技能的工具路徑,三件都會影響 已經裝在機器上的那一份。版本不動,版本閘門就看不出哪台機器裝的是舊的 那一版,也提不出重啟要求,舊條目會繼續空轉到有人自己發現。 三份檔案只動 version 一欄,其餘不碰,三份保持同一個號碼,這樣不管從哪 一支 CLI 讀到的都是同一個答案。功能範圍是外掛的版本宣告。 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bed972f..9ad5ba5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.0", + "version": "0.1.1", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 7c8ed1a..5afcd41 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.0", + "version": "0.1.1", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/plugin.json b/plugin.json index 5e17884..df2c376 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.0", + "version": "0.1.1", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { From 02bfc70f4e103c238196ca4b47232f9dcaa5c3f0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 18:54:05 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(assistant):=20=E8=AE=93=E5=9F=B7?= =?UTF-8?q?=E8=A1=8C=E8=B7=AF=E5=BE=91=E8=B5=B0=E9=8C=AF=E8=88=87=E8=AD=A6?= =?UTF-8?q?=E7=A4=BA=E5=8E=9F=E5=9B=A0=E9=83=BD=E7=9C=8B=E5=BE=97=E8=A6=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩支腳本開跑就檢查自己的執行路徑,監控頁的摘要表與最新一輪那一塊補上 「警示來源」。 兩件都是實測之後補的。用全新的 CLI 行程跑,代理人會把工具路徑解回外掛 快取那一份,不是指定的工作樹那一份。原本的對策只在技能說明書裡寫明禁 止,那是文件約束,擋不住真的走錯的那一輪。權限閘門只放行 current 那一 組確切路徑,走錯就被靜靜擋掉,那一輪不寫心跳,外面只看得到心跳過期。 另一頭,沙箱跑出來的摘要列是「警示、四項全過、待人處理 0」,讀的人看 不出警示哪來。查過不是缺陷:四項來源都讀得到,是讀到的內容有警示。摘 要表的用途本來就是一眼看出從哪一輪開始壞,少了原因那一欄就做不到。 巡檢與排程兩支腳本開跑時先比對自己是不是從 current 底下被叫起來的,不 是就往 stderr 印一行警告,點名實際路徑、應該用的路徑與理由。刻意只警告 不中止:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉,真 正的失敗本來就發生在權限閘門那裡。摘要表從四欄加到五欄,最新一輪那一 塊也補一列,值取每一處設警示時收下來的短理由,多個用頓號串,沒有就寫 「無」。腳本裡每一處把判定改成警示的地方,一律改走同一支函式順手收下 理由,collect 的輸出多印一行 warn_sources。技能主文、行為清單與監控頁 範本跟著寫明這兩件事。 功能範圍是助理的一輪巡檢與系統排程安裝。 --- references/behaviors.md | 4 +-- skills/assistant/SKILL.md | 8 ++++-- templates/monitor-page.md | 9 ++++-- tools/patrol.sh | 58 ++++++++++++++++++++++++++++++--------- tools/schedule.sh | 16 +++++++++++ 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/references/behaviors.md b/references/behaviors.md index 65d6b93..ddd3e59 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -7,7 +7,7 @@ | 項目 | 內容 | | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。執行環境健檢不走這支,走 `jsc-cli:doctor`。技能使用次數不走這支,走 `jsc-log:stats` | -| 關鍵步驟 | 先認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀四項來源、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把這一輪寫上監控頁、`hash` 是空的就 `abort`、經 `jsc-gitea:wiki` 讀回 `MONITOR_{HASH}` 舊頁、基本資料原樣留著、最新一輪那一塊整塊換成 `latest_file`、`summary_file` 的本輪那一列擺最上面、舊的資料列接在下面並截到 24 列、三塊重組成整頁寫回、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、讀不回舊頁就不寫、把 `contents_file` 的 `row` 更新到 `MONITOR_CONTENTS` 自己那一列、兩次寫入任一失敗就 `abort` 且不寫心跳、全部寫成才跑 `tools/patrol.sh finish` 寫心跳、最後印出四項結果與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人 | +| 關鍵步驟 | 先認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀四項來源、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把這一輪寫上監控頁、`hash` 是空的就 `abort`、經 `jsc-gitea:wiki` 讀回 `MONITOR_{HASH}` 舊頁、基本資料原樣留著、最新一輪那一塊整塊換成 `latest_file`、`summary_file` 的本輪那一列擺最上面(五欄:巡檢時間、本輪判定、四項成敗、待人處理、警示來源)、舊的資料列接在下面並截到 24 列、三塊重組成整頁寫回、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、讀不回舊頁就不寫、把 `contents_file` 的 `row` 更新到 `MONITOR_CONTENTS` 自己那一列、兩次寫入任一失敗就 `abort` 且不寫心跳、全部寫成才跑 `tools/patrol.sh finish` 寫心跳、最後印出四項結果、判成警示時的警示來源與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人 | | 外部呼叫 | 工具一律走 `$JSC_HOME/current/{外掛名}` 那一組不帶版本的路徑:`current/jsc-assist/tools/patrol.sh`、`current/jsc-assist/tools/schedule.sh`、`current/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `current/jsc-gitea/tools/gitea.sh`,`$JSC_HOME` 沒設就退回 `~/.jsc`;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會查 `$JSC_HOME/current/jsc-assist` 與 `$JSC_HOME/current/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目、條目自帶 `JSC_GITEA_CONFIRM=yes`、並印出這一輪要開的 `allow_rule=` 規則(四支腳本各三種呼叫形式,含 `gitea.sh`——`Skill(jsc-gitea:wiki)` 只放行叫用技能,技能內部的 Bash 呼叫仍各自受檢;路徑是 `current` 那一組確切路徑,不用萬用字元);七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat`、週期塞不進門檻、判不出 CLI,以及那一支 CLI 的執行檔不在 `PATH` 上。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort` 三個子命令,七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是四項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那四項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`,全部只讀,任一項失敗不影響其餘三項。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | | 完成條件 | `start` 要那一輪巡檢的 `finish` 回 0 且 `report` 回 `state=fresh`,才算啟動成功;巡檢沒寫成心跳一律回報失敗並停下,不得宣稱啟動;`schedule.sh install patrol` 回 1 要講明條目不會被執行與 `sudo service cron start`,不得宣稱排程會定時執行;回 0 或 1 都要把 `allow_rule=` 各行、「條目含金鑰快照、變數改了要重裝」這句提醒,以及 `current` 連結缺漏的警告轉出去。`patrol` 要四項各自有 `status`、監控頁三塊重組寫成、目錄頁那一列更新成功、`finish` 回 0,才算一輪跑完;`collect` 回 4 是讓開,不算失敗也不寫任何東西;舊頁讀不回來就不寫,回報「這一輪沒有結果」;監控頁或目錄頁任一沒寫成就 `abort`,心跳一定不寫。`status` 要印出現況表,或印出「助理未運行」並說明原因;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝,四種都算正常結束。`stop` 要 `schedule.sh remove all` 先回 0、`clear` 再回 0,並印出帶三段話的停止訊息;`remove` 非 0 就回報排程還在、助理停不掉,不清心跳也不印停止訊息;`clear` 回 5 就回報心跳檔還在、助理沒有確實停掉,不印停止訊息 | -| 可驗證跡象 | `start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照;install 印出的 `allow_rule=` 都是 `$JSC_HOME/current` 那一組確切路徑,沒有萬用字元,也沒有 `Write(...)`。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 只有三塊:基本資料一字未改、最新一輪換成本輪、摘要表最上面一列是本輪且總列數不超過 24,`MONITOR_CONTENTS` 只有自己那一列變動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `latest.md`、`summary.md`、`summary-row.md`、`newpage.md`、`contents.tsv`,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/patrol.lock` 已經放掉。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作都不動 `tasks/` 底下的檔案,也不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | +| 可驗證跡象 | `start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照;install 印出的 `allow_rule=` 都是 `$JSC_HOME/current` 那一組確切路徑,沒有萬用字元,也沒有 `Write(...)`。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 只有三塊:基本資料一字未改、最新一輪換成本輪、摘要表最上面一列是本輪且總列數不超過 24,`MONITOR_CONTENTS` 只有自己那一列變動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `latest.md`、`summary.md`、`summary-row.md`、`newpage.md`、`contents.tsv`,摘要列是五欄、警示來源那一欄有值或寫「無」;兩支腳本不是從 `$JSC_HOME/current` 跑起來時,stderr 會有一行 `[WARN]` 點出實際路徑與應該用的路徑,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/patrol.lock` 已經放掉。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作都不動 `tasks/` 底下的檔案,也不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | diff --git a/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index 8683fcc..a709e32 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -30,6 +30,8 @@ Every tool below is addressed through `$JSC_HOME/current/{plugin}`, and `$JSC_HO **Never build a tool path out of the base directory the CLI hands you in the skill prompt.** That directory points into the plugin cache and carries a version segment, and the permission gate allows exactly the four paths above and nothing else. A cache path is therefore refused silently: the round stops on a permission prompt nobody can answer, records nothing, writes no heartbeat, and the refusal looks exactly like a broken tool. Read the paths off this table every time — not off the prompt, not off a previous transcript, not off `crontab -l`. +Both scripts check this for themselves: run from anywhere outside `$JSC_HOME/current`, they print a `[WARN]` line on stderr naming the path they were started from and the path they should have been started from, and then carry on. That line means this round is on the wrong path — quote it, fix the path, and do not treat the round's success as proof that the path was fine. + `current` is a set of version-free links that `jsc-cli:deploy` maintains, so an upgrade moves the cache and leaves these paths alone. When one of them is missing, report the missing link and say `jsc-cli:deploy` has to run; never fall back to a cache path to get the round through, and never create the link here. ## Pick the operation @@ -167,7 +169,7 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by One round: read four sources, record the result, then beat. Everything before the heartbeat is read-only except the round's own scratch files. Ask nobody anything. -1. **Collect.** Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `pending=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=` and `contents_file=`. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped. +1. **Collect.** Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=` and `contents_file=`. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped. 2. **Check the page name.** An empty `hash=` means `jsc-gitea/tools/hash-id` could not be found or could not run, so there is no page to write to and nothing can be recorded. Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh abort --round {round}`, report that the round found its results but has nowhere to put them, name `jsc-gitea` as missing, and stop. Never invent a page name — a hand-made name lands the content on a page nobody reads. Completion condition: `page=` holds a `MONITOR_{HASH}` name, or the abort ran and the round was reported as unrecorded. @@ -177,7 +179,7 @@ One round: read four sources, record the result, then beat. Everything before th | --- | --- | | 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived | | 最新一輪 | the whole content of `latest_file`, replacing the old block entirely | - | 近 24 輪摘要 | `summary_file`, which already holds the heading, the table header and this round's row; then the old table's data rows in their old order underneath, cut so the table holds at most 24 rows | + | 近 24 輪摘要 | `summary_file`, which already holds the heading, the five-column table header (`巡檢時間`、`本輪判定`、`四項成敗`、`待人處理`、`警示來源`) and this round's row; then the old table's data rows in their old order underneath, cut so the table holds at most 24 rows | Put the whole page. An old-format page — per-round sections stacked up, no summary table — has no rows to carry over: keep its `本頁基本資料` block, drop the stacked sections, let the table start with this round's row, and say in the report that the page was converted. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `$JSC_HOME/current/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **No record, no heartbeat.** Completion condition: the put or the create returned success and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code. @@ -185,7 +187,7 @@ One round: read four sources, record the result, then beat. Everything before th 5. **Write the heartbeat.** Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code. -6. **Report the round.** Print the round verdict, one line per item with its `status=` and, for a failure, its `note=`; the monitor page name and the contents row that was written; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL. Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all four items appear in the report, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on. +6. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all four sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name and the contents row that was written; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL. Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all four items appear in the report, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on. ## status diff --git a/templates/monitor-page.md b/templates/monitor-page.md index f67211b..50d319e 100644 --- a/templates/monitor-page.md +++ b/templates/monitor-page.md @@ -43,6 +43,7 @@ flowchart LR | 本輪判定 | {正常、警示、異常 三選一} | | 本輪項目 | {這一輪跑了哪幾項,成功幾項、失敗幾項} | | 讀不到的來源 | {路徑清單,全部讀得到就寫「無」} | +| 警示來源 | {警示原因,多個用頓號串;沒有就寫「無」} | ### 心跳與閘門狀態 @@ -120,9 +121,11 @@ flowchart LR 一輪一列,最新的在最上面,超過 24 列就丟掉最舊的那一列。 -| 巡檢時間 | 本輪判定 | 四項成敗 | 待人處理 | -| --- | --- | --- | ---: | -| {yyyy-MM-dd HH:mm} | {正常、警示、異常 三選一} | {成功項數}/{總項數} | {待人處理筆數} | +| 巡檢時間 | 本輪判定 | 四項成敗 | 待人處理 | 警示來源 | +| --- | --- | --- | ---: | --- | +| {yyyy-MM-dd HH:mm} | {正常、警示、異常 三選一} | {成功項數}/{總項數} | {待人處理筆數} | {警示原因,多個用頓號串;沒有就寫「無」} | + +「警示來源」那一欄不能省。四項讀取全部成功、但讀到的內容有警示時,判定是警示而成敗欄是 4/4,沒有這一欄的話,看的人不知道警示哪來。理由要短,一眼讀完,像「心跳過期」「版本查詢失敗」「重啟閘門未清」「上一輪逾時被接手」。 ## 寫入規則 diff --git a/tools/patrol.sh b/tools/patrol.sh index 791cfa8..ae4a21d 100755 --- a/tools/patrol.sh +++ b/tools/patrol.sh @@ -87,6 +87,8 @@ # item= 一項一行,欄位 status(ok、empty、fail)、rc、note # verdict= 正常、警示、異常 # failed_sources= 讀不到的來源路徑,以「、」分隔;全部讀得到就是「無」 +# warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。四項全過卻判成警示 +# 時,原因只寫在這裡 # tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,只供目錄頁那一列用 # pending= 本輪待人處理的筆數 # latest_file= 「最新一輪」那一塊,整塊換掉舊頁同名那一塊 @@ -120,10 +122,34 @@ ROUND='' DRYRUN=0 LOCK_BROKEN=0 FAILED_SOURCES='' +WARN_SOURCES='' OK_COUNT=0 FAIL_COUNT=0 WARN=0 +# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。 +# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉; +# 真正的失敗會發生在權限閘門那裡——閘門只放行 current 那一組確切路徑,用別的路徑那一輪會 +# 被靜靜擋掉、失敗,而且不會寫心跳,外面只看得到心跳過期。這裡先把話講在前面。 +warn_if_not_current() { + _self="$SCRIPT_DIR/$(basename -- "$0")" + _want="$CURRENT/jsc-assist/tools/$(basename -- "$0")" + case "$SCRIPT_DIR/" in + "$CURRENT"/*) return 0 ;; + esac + printf '[jsc][助理巡檢][WARN]:這支腳本是從 %s 跑起來的,不是 %s。權限閘門只放行 current 那一組確切路徑:排程那一輪用別的路徑會被靜靜擋掉,那一輪失敗、不寫心跳,外面只看得到心跳過期。開發時這樣跑沒關係,無人值守那一輪一律走 current。\n' \ + "$_self" "$_want" >&2 + return 0 +} +warn_if_not_current + +# 記一個警示來源。每一處把 WARN 設成 1 的地方都經過這裡,摘要表那一欄才看得出警示哪來—— +# 四項全過卻判成警示,光看成敗欄是查不出原因的。 +add_warn() { # $1=一句話講完的理由 + WARN=1 + if [ -z "$WARN_SOURCES" ]; then WARN_SOURCES="$1"; else WARN_SOURCES="$WARN_SOURCES、$1"; fi +} + usage() { cat >&2 <<'EOF' usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動] @@ -224,7 +250,7 @@ lock_acquire() { printf 'round=%s\npid=%s\nstarted=%s\n' "$ROUND" "$$" "$(date +%s)" >"$LOCK/info" 2>/dev/null \ || die 5 "鎖搶回來了,卻寫不進 $LOCK/info。" LOCK_BROKEN=1 - WARN=1 + add_warn '上一輪逾時被接手' return 0 } @@ -361,7 +387,7 @@ d04() { [ "$_rows" -eq 0 ] && printf '| (無 domain) | - | - | 這台機器一個 jsc plugin 都沒查到 |\n' >>"$RD/d04.md" printf '\n判定欄照 `version-guard.sh report` 第四欄原字抄。抄到「查詢失敗」就寫「查詢失敗」,不改寫成「相符」或「最新」,也不自己補查遠端版本——查不到是沒有證據,不是版本沒問題。\n\n' >>"$RD/d04.md" if [ "$_unver" -gt 0 ]; then - WARN=1 + add_warn '版本查詢失敗' printf '**本輪有 %s 列查不到遠端版本。** 同一支腳本在擋人那條路徑查得到遠端版本,report 這條查不到,這是既有缺陷,不是這台機器的網路問題。\n\n' "$_unver" >>"$RD/d04.md" add_pending 'version-guard.sh report 查不到遠端版本,版本落差本輪無證據' '版本落差與重啟閘門' '/jsc-cli:doctor' fi @@ -396,7 +422,7 @@ d04() { if [ "$_up" -eq 0 ]; then printf '| (無) | 未升起 | - |\n' >>"$RD/d04.md" else - WARN=1 + add_warn '重啟閘門未清' fi fi else @@ -517,10 +543,10 @@ d09() { HEARTBEAT_TTL="$_ttl" case "$_st" in fresh) HEARTBEAT_STATE='新鮮' ;; - stale) HEARTBEAT_STATE='過期'; WARN=1 ;; - invalid) HEARTBEAT_STATE='心跳檔損壞'; WARN=1 ;; - absent) HEARTBEAT_STATE='不存在'; WARN=1 ;; - *) HEARTBEAT_STATE="判不出(state=${_st:-空值})"; WARN=1 ;; + stale) HEARTBEAT_STATE='過期'; add_warn '心跳過期' ;; + invalid) HEARTBEAT_STATE='心跳檔損壞'; add_warn '心跳檔損壞' ;; + absent) HEARTBEAT_STATE='不存在'; add_warn '心跳不存在' ;; + *) HEARTBEAT_STATE="判不出(state=${_st:-空值})"; add_warn '心跳判不出' ;; esac { printf '| 項目 | 內容 |\n' @@ -593,6 +619,9 @@ compose() { printf '| 本輪判定 | %s |\n' "$VERDICT" printf '| 本輪項目 | 四項:D-01 使用統計、D-04 版本與重啟閘門、D-07 階段鎖與工作包鎖、D-09 心跳自述。成功 %s 項、失敗 %s 項 |\n' "$OK_COUNT" "$FAIL_COUNT" printf '| 讀不到的來源 | %s |\n' "$(cell "${FAILED_SOURCES:-無}")" + # 警示來源緊接在讀不到的來源後面:兩列語意相近,而且四項讀取全部成功、判定卻是警示 + # 時,這一塊裡只有這一列講得出原因,跟摘要表那一欄是同一個理由。 + printf '| 警示來源 | %s |\n' "$(cell "${WARN_SOURCES:-無}")" if [ "$LOCK_BROKEN" -eq 1 ]; then printf '| 鎖 | 上一輪的鎖逾時,本輪搶回來了。上一輪沒跑完,那一輪不會寫心跳 |\n' fi @@ -613,17 +642,19 @@ compose() { if [ -s "$RD/pend.md" ]; then cat "$RD/pend.md"; else printf '| (無) | - | - |\n'; fi } >"$RD/latest.md" - # 摘要表的那一列。欄位刻意只有四個:時間、判定、四項成敗、待人處理筆數——一列要能一眼 - # 看完,才看得出是從哪一輪開始壞的。 - printf '| %s | %s | %s/%s | %s |\n' \ - "$AT" "$VERDICT" "$OK_COUNT" "$ITEM_TOTAL" "$PEND_COUNT" >"$RD/summary-row.md" + # 摘要表的那一列。欄位刻意只有五個,一列要能一眼看完,才看得出是從哪一輪開始壞的。 + # 「警示來源」那一欄不能省:四項讀取全部成功、但讀到的內容有警示時,判定是警示而成敗欄 + # 是 4/4,沒有這一欄的話,看的人不知道警示哪來。 + printf '| %s | %s | %s/%s | %s | %s |\n' \ + "$AT" "$VERDICT" "$OK_COUNT" "$ITEM_TOTAL" "$PEND_COUNT" \ + "$(cell "${WARN_SOURCES:-無}")" >"$RD/summary-row.md" # 摘要那一塊:標題、表頭,加上本輪這一列。呼叫端把舊頁的資料列接在這一列下面,截到 24 列。 { printf '## 近 24 輪摘要\n\n' printf '一輪一列,最新的在最上面,超過 24 列就丟掉最舊的那一列。\n\n' - printf '| 巡檢時間 | 本輪判定 | 四項成敗 | 待人處理 |\n' - printf '| --- | --- | --- | ---: |\n' + printf '| 巡檢時間 | 本輪判定 | 四項成敗 | 待人處理 | 警示來源 |\n' + printf '| --- | --- | --- | ---: | --- |\n' cat "$RD/summary-row.md" } >"$RD/summary.md" @@ -733,6 +764,7 @@ case "$CMD" in printf 'item=D-09 status=%s rc=%s note=%s\n' "$D09_STATUS" "$D09_RC" "$D09_NOTE" printf 'verdict=%s\n' "$VERDICT" printf 'failed_sources=%s\n' "${FAILED_SOURCES:-無}" + printf 'warn_sources=%s\n' "${WARN_SOURCES:-無}" printf 'tasks_total=%s\n' "$TASKS_TOTAL" printf 'tasks_failing=%s\n' "$TASKS_FAILING" printf 'pending=%s\n' "$PEND_COUNT" diff --git a/tools/schedule.sh b/tools/schedule.sh index c7ee6cc..b471f57 100755 --- a/tools/schedule.sh +++ b/tools/schedule.sh @@ -123,6 +123,22 @@ PERIOD='' SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) SCRIPT_DIR="${SCRIPT_DIR:-.}" +# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。 +# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉; +# 真正的失敗會發生在權限閘門那裡——閘門只放行 current 那一組確切路徑,用別的路徑那一輪會 +# 被靜靜擋掉、失敗,而且不會寫心跳,外面只看得到心跳過期。這裡先把話講在前面。 +warn_if_not_current() { + _self="$SCRIPT_DIR/$(basename -- "$0")" + _want="$CURRENT/jsc-assist/tools/$(basename -- "$0")" + case "$SCRIPT_DIR/" in + "$CURRENT"/*) return 0 ;; + esac + printf '[jsc][助理排程][WARN]:這支腳本是從 %s 跑起來的,不是 %s。權限閘門只放行 current 那一組確切路徑:排程那一輪用別的路徑會被靜靜擋掉,那一輪失敗、不寫心跳,外面只看得到心跳過期。開發時這樣跑沒關係,無人值守那一輪一律走 current。\n' \ + "$_self" "$_want" >&2 + return 0 +} +warn_if_not_current + usage() { cat >&2 <<'EOF' usage: schedule.sh install [patrol|all] [--dry-run] [--cli 代號] [--patrol-cmd 指令] [--period 分鐘]