diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 04fba75..9c9c082 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.2.8", + "version": "0.2.9", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 32e582d..b7b05dc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.2.8", + "version": "0.2.9", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/plugin.json b/plugin.json index 1c94c39..9135b82 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.2.8", + "version": "0.2.9", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { diff --git a/references/behaviors.md b/references/behaviors.md index 5d5f3bd..9d5c011 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -8,6 +8,6 @@ | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。委派清單改過之後要讓助理的內建定期檢查項跟著重建,也走這一支的 `start`;只想知道差在哪、不要動待辦簿就走 `status`。執行環境健檢不走這支,走 `jsc-cli:doctor`。技能使用次數不走這支,走 `jsc-log:stats` | | 關鍵步驟 | 四個操作都先跑同一個前置步驟,取得工具根目錄(本頁記成 `{CURRENT}`),根目錄一律由外面餵進來:排程那一輪從叫用文字裡的「工具根目錄=」那一段取字面絕對路徑,一個指令都不跑;人在現場叫用時,叫用文字帶那一段就取那一段,沒帶才跑一次 `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"` 自己解,那一次會跳一次權限詢問,人按一下就過。無人值守那一輪取不到根目錄就停下回報:說明條目是舊版 `schedule.sh` 裝的、沒有把根目錄寫進提示文字,叫人重跑一次 `start` 或 `schedule.sh install patrol` 把條目重寫,收尾狀態取 `aborted`;一律不跑 `readlink`、不跑 `ls`、不退回帶變數的路徑、不拿技能提示或上一次轉錄裡的路徑、也不猜。整次叫用只取這一次,之後每一次腳本呼叫都填那一個字面絕對路徑,不是每一次呼叫各取一次,也不另外加印路徑的工具,更不另外跑指令去驗那一個路徑。取到的是空的、不是絕對路徑、或那條路徑不是存在的目錄,就回報根目錄不見了、叫人跑 `jsc-cli:deploy`,收尾狀態取 `aborted`;第四項要單獨查:解析式帶了預設值之後,`JSC_HOME` 沒設不再解成 `/current`,但 `JSC_HOME` 指到已經不存在的目錄、`HOME` 在精簡環境裡沒設、連結農場根本還沒部署,三種都一樣會交出一條非空又絕對、前三項全過得了關的路徑,所以人在現場那一次要在同一步再跑 `[ -d "{剛印出來的路徑}" ]`,目錄存在才算取到根目錄;排程那一輪不查,它的根目錄是裝排程的人寫進條目的,根目錄不對就會在第一支腳本呼叫上失敗。除了人在現場那一次 `readlink`,任何指令列都不得出現 `$JSC_HOME`、`${JSC_HOME}` 或 `~`:權限層比對的是還沒展開的指令字面。實測歸納出兩條判準:一、無人值守時只有允許清單上的完整字面指令跑得動,沒有「預設安全的唯讀指令」這回事,連 `readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"` 與沒有規則的 `ls -d /root/.jsc/current` 都被擋;二、路徑中段的萬用字元不匹配,版本號寫成 `*` 的快取路徑規則一樣擋,規則與指令都必須是完整字面。排程那一輪沒有人可以按同意,被擋就是停在第一支腳本,什麼都不記,心跳也寫不出來。接著認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先跑 `tools/seed-tasks.sh apply --root {CURRENT}` 把內建定期檢查項對齊委派清單(清單在 `{CURRENT}/jsc-meta/tools/delegate-spec.tsv`,根目錄一律用 `--root` 餵進去、那一支自己不解),清單上可交而待辦簿沒有的就加一筆、待辦簿有而清單上已經沒有或改成不交的就移除、`origin` 是 `user` 的一律不動、判定是 `cond` 的預設保留不種入並印出條件原文、`trigger`、`recur` 或 `action` 變了只印 `drift=` 不改那一筆(上游把 `probe` 那一欄合併進來的那一輪,四筆指令型就是走這一條,要換值得人親自帶 `--refresh`);種入與重建是同一個呼叫、冪等,所以每一次 `start` 都跑,第二次跑不會重複建;結束碼 1、2、3 都是「一筆都沒動」,不中止啟動,照實記進收尾回報再往下走,4 是部分失敗、成功的那幾筆算數,一律不帶 `--force` 也不自己帶 `--allow-cond`;接著照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、`patrol_root=`(條目寫進去的字面根目錄,之後每一輪都從那裡讀)、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀五項來源(那一輪另外會自己叫一次 `tools/due.sh scan`,把待辦簿的事件偵測與到期判定寫成「待辦簿到期與逾期」那一節,技能本文一律不自己再叫一次——`scan` 會推進事件快照,同一輪叫第二次就比不出任何事件,而那一次會回報零事件、看起來完全正常;要看下一輪會判出什麼就叫 `due.sh events`,那個子命令一律唯讀)(第五項是執行狀態事件:`collect` 自己叫 `jsc-hooks/tools/report-status.sh drain` 排空,緊接著跑 `rotate`,再把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成監控頁那一節;技能本文一律不自己再跑一次 `drain`)、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把這一輪寫上監控頁、`hash` 是空的就 `abort`、經 `jsc-gitea:wiki` 讀回 `MONITOR_{HASH}` 舊頁、基本資料原樣留著、最新一輪那一塊整塊換成 `latest_file`、`summary_file` 的本輪那一列擺最上面(五欄:巡檢時間、本輪判定、各項成敗、待人處理、警示來源)、舊的資料列接在下面並截到 24 列、三塊重組成整頁、寫回之前先把這一頁要放進去的每一個連結交給 `jsc-gitea/tools/link-check.sh`(結束碼 0 才整頁寫回,結束碼 1 就把 DEAD 那幾筆原樣回報並 `abort`,2、3、7 同樣 `abort`,一個連結都沒有就跳過這一次驗證並照實說明)、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、讀不回舊頁就不寫、監控頁寫成之後跑 `gitea.sh wiki-url` 取那一頁的絕對網址並依結束碼分流(4 回步驟三重寫、5 沒有 `html_url`、7 與 8 走 `abort`,其餘非 0 也走 `abort`,網址取不到就不寫那一個區塊)、換掉 `contents_file` 那個 H2 區塊裡 `{監控頁絕對網址}` 那個佔位、換完再用 `link-check.sh` 驗那一個網址(結束碼 0 才寫那一個區塊;非 0 一律不寫,比照目錄頁結束碼 3 當成那一個區塊沒更新、這一輪照樣往下寫心跳,並把連不到的那一筆列進待人處理)、用 `jsc-gitea/tools/wiki-contents.sh upsert MONITOR 1 "MONITOR_{HASH}" {區塊檔}` 以 H2 標題(也就是內容頁頁名,取 `collect` 印的 `page=`)當鍵更新 `MONITOR_CONTENTS` 自己那一個區塊並一律帶上 `templates/monitor-contents.md` 當範本(第三個參數 `1` 是 `key-col`,只在舊頁還是 markdown 表格時用得到:舊表格第 1 欄「監控頁」持有身分,那一格是 `[MONITOR_{HASH}](網址)`,轉檔時只取文字當標題;頁面已經是條列格式時這個參數被忽略,照樣固定給 `1`)、目錄頁回 3(`CONTENTS` 存取庫沒設定)不中止這一輪,照樣往下寫心跳,並把「設 `JSC_WIKI_REPO_CONTENTS` 或 `JSC_WIKI_REPO`」列進待人處理、監控頁任一失敗或目錄頁其餘非 0 才 `abort` 且不寫心跳、跑 `tools/patrol.sh finish` 寫心跳、最後印出各項結果、本輪事件數與非 ok 事件數、非 ok 事件的明細(kind、name、status、exit、detail)、以及有 start 沒有配對 end 的那幾支技能(單獨列,那代表那一輪中止了)、兩次寫入各自的連結驗證結果(通過、無連結而跳過、或被擋下並附結束碼與 DEAD 明細)、判成警示時的警示來源與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話、最後跑 `tools/seed-tasks.sh plan --root {CURRENT}` 唯讀比對內建項與委派清單並印出差在哪(該加幾筆、還剩幾筆孤兒、保留的 `cond` 各是哪一支、`drift=` 各要換什麼值),一律不跑 `apply`,並說明要套用差異就跑 `start`;那一支回 1、2、3 就照實說比不出來、不說成已對齊。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人。四個操作最後都一樣:回報印完之後跑一次 `jsc-hooks/tools/report-status.sh skill-end jsc-assist:assistant {status} {結束碼}`,`start` 由 hook 記、`end` 由這裡寫,不寫就等於這一次自己看起來中止了 | -| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-assist/tools/due.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{CURRENT}/jsc-gitea/tools/wiki-contents.sh`,連結驗證那一支是 `{CURRENT}/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `{CURRENT}/jsc-hooks/tools/report-status.sh`,範本是 `{CURRENT}/jsc-assist/templates/monitor-contents.md`;`JSC_HOME` 沒設時,人在現場那一次 `readlink` 自己退回 `~/.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` 會查 `{CURRENT}/jsc-assist` 與 `{CURRENT}/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把只放得下 CLI 執行檔那幾個目錄的 `PATH`(不是整條照抄——實測整條 2160 字元,crontab 一行有長度上限,照抄會回「command too long」而裝不起來;帶會過期的安裝目錄時重跑一次 `install` 就重新拍一份)、`GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(`PATH` 一定要帶:cron 給的只有 `/usr/bin` 與 `/bin`,而 CLI 執行檔多半裝在家目錄底下,少了它那一輪偵測 CLI 會回結束碼 0 加一份空清單,看起來像這台機器沒裝 CLI;巡檢要跑的指令本身走字面絕對路徑,不靠這一條)(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、刻意不寫死工作階段代號(寫死會讓那一輪的 `start` 與 `end` 落在不同的代號上:前者由 hook 從標準輸入的 JSON 讀 CLI 真正的代號,後者由工具腳本只讀得到環境變數,兩半永遠配不起來,每一輪都被算成一支中止的技能)、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `patrol_root=`、`--patrol-cmd` 或 `JSC_ASSIST_PATROL_CMD` 給的自訂指令沒帶那一段時只警告不中止、並印出這一輪要開的 `allow_rule=` 規則(七支腳本各三種呼叫形式,含 `gitea.sh`、`wiki-contents.sh`、`link-check.sh` 與 `jsc-hooks/tools/report-status.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` 上,以及 `JSC_HOME` 解不出絕對路徑(條目寫不出字面根目錄)。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort`、`rebuild` 四個子命令(`collect` 除了 `due_file=` 也轉出 `due_rows_file=`,那是機器可讀的到期清單,執行那一步要拿它當 `--rows`;`rebuild` 拿讀回來的舊頁把三塊接成要寫回去的整頁,`--old` 指舊頁檔、印 `rebuilt_file=` 與 `old_format=`;接法固定所以寫死在腳本裡,不留給呼叫端臨場挑工具——實測某一輪挑了 `awk` 被權限擋掉兩次,那一輪有人在場才繞得過去,而暫存目錄已經累積 124 個同一件事的檔案;基本資料那一塊逐字照抄,連尾端空行都不動),七個結束碼各有處置: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`、`jsc-hooks/tools/report-status.sh drain` 與 `rotate`,除了排空會把事件流的位移往前推之外全部只讀,任一項失敗不影響其餘各項。`report-status.sh` 三個結束碼各有處置:0 是排空到新事件、3 是沒有新事件(正常狀態,不是失敗)、2 是呼叫寫錯;找不到這一支、`drain` 回 0 與 3 以外的碼、或 `rotate` 回非 0,都只讓這一項標成失敗或記一筆警示,一律不中止那一輪——回報鏈自己壞掉不可以把被回報的那一輪拖下去。`rotate` 只在 `drain` 成功時緊接著跑:中間隔越久,那段時間新寫進來的事件被搬進備份檔而從此排不到的機會越大;排空失敗時位移狀態未知,這時候輪替會直接吃掉還沒排空的那一批。配對以 `session` 加 `name` 為鍵,不只看 `name`:五支 CLI 併發時同一支技能會有好幾個工作階段同時在跑。沒配對到的 `start` 留在 `$JSC_HOME/assistant/events-open.tsv` 跨輪繼續配對,開超過心跳門檻才算疑似中止,未達門檻的算還在跑,超過一天沒配對到就丟掉。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫;只有目錄頁那一個 H2 區塊例外,走 `jsc-gitea/tools/wiki-contents.sh upsert`,它自己解 `CONTENTS` 存取庫、自己讀回整頁比對標題,舊頁還是 markdown 表格時自己先整頁轉成 H2 區塊再寫,七個結束碼各有處置:0 已更新或已新增、1 組不出頁面內容或寫入失敗要 `abort`(找不到同名標題不算錯,那是附加)、2 參數錯就改正重跑(範本路徑不存在也回這一碼,代表 plugin 沒裝齊)、3 是 `CONTENTS` 存取庫未設定且**不中止這一輪**、4 是頁不存在又沒給範本,本技能一律帶第五個參數所以不會出現、7 金鑰失效要 `abort`、8 其他 API 失敗要 `abort`。比對鍵取 H2 標題,也就是內容頁頁名 `MONITOR_{HASH}`,不取「監控頁」那一條的連結:連結含 `GITEA_HOST` 與頁名的網址編碼,那三樣一變鍵就對不上,同一台機器每輪多附一個區塊;頁名只由 `{主機名}/{登入帳號}` 決定,那三樣都動不到它。連結一律寫成 `[{文字}]({絕對網址})`,網址只取 `gitea.sh wiki-url` 印的那一個、不自己組路徑,那一支的結束碼 4、5、7、8 與其餘非 0 各有處置;每一個要放進頁面的連結在寫入前先過 `jsc-gitea/tools/link-check.sh`,它每個網址印一行 `{OK|DEAD|SKIP}` 加網址加說明,五個結束碼各有處置:0 才准寫入、1 有連不到的就不寫並回報 DEAD 那幾筆、2 是一個網址都沒給要補參數重跑、3 是 `GITEA_HOST` 未設定要先設定且不得跳過驗證、7 是金鑰失效要停下來回報金鑰問題而不是當成死連結;驗證走 API 不看網頁狀態碼,私有存取庫的網頁網址對未登入請求一律回 404。頁名雜湊一律取 `gitea.sh hash-id`/`tools/hash-id` 印的完整 40 碼大寫十六進位,不截短、不加前綴、不手算,空輸入回 2。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。待辦簿的存放格式與讀寫入口是本 domain 的 `tools/tasks.sh`:一筆一檔、純文字 key=value、十五個鍵順序固定、值是空的照樣寫出那一行,讀的時候只在第一個等號斷開,寫的時候把值折成一行,一筆一檔的理由同 `restart-required.d`(並行寫入不互相覆寫),`id` 取共用 hash 規則那四十碼的前 8 碼、碰撞時每次加長兩碼,七個子命令 `list`、`add`、`done`、`fail`、`pause`、`resume`、`remove` 與八個結束碼的完整說明寫在那一支的檔頭;第十五個鍵是 `spec_key`,值是 `jsc-{domain}:{技能名}`,那是從一支技能反查到它對應那一筆內建項的唯一把手(`id` 是建立時間加標題的雜湊、反查不了;標題與 `action` 拿來當鍵會撞上使用者交辦的那幾筆),只有 `origin=assistant` 帶得上它,`--spec-key` 配 `--origin user` 回 2,`remove` 對 `origin=user` 的那一筆一律回 7、除非人親自帶 `--force`。內建定期檢查項照委派清單種入與重建的入口是本 domain 的 `tools/seed-tasks.sh`,兩個子命令 `plan`(唯讀預覽)與 `apply`(真的做),清單路徑取 `--root` 餵進來的那一個字面絕對路徑底下的 `jsc-meta/tools/delegate-spec.tsv`;欄位對映是 `trigger` 與 `recur` 原樣抄、`spec_key` 由清單前兩欄合成、`action` 由 `way` 與第十二欄 `probe` 一起推、`title` 固定寫成「委派清單內建項:{spec_key}」以免清單一改就換 `id`、`kind` 一律 `check`、`origin` 一律 `assistant`、`repo` 與 `due` 一律留空,清單的 `verdict`、`slice`、`human`、`next`、`version` 與它自己的 `origin` 欄一律不抄(清單的 `origin` 是 `seed` 或 `judged`,與待辦簿的 `origin` 同名不同義);`action` 那一欄的推法分兩路:`way` 含 `invoke` 就取技能名、`probe` 連看都不看(填了指令會讓整支交出變成只跑一支腳本,那支技能該寫的頁一頁都不會寫,所以那是清單填錯,照 `way` 取技能名並印一行 `probe_bad=`),其餘那幾種交出方式照 `probe` 走——一行指令就取那一行指令、`pending:{理由}` 取 `remind` 並印一行 `pending=`、減號或空的取 `remind`;`probe` 代不進去一律退回 `remind` 並印一行 `probe_bad=`,照樣種入那一筆,涵蓋路徑不是 `{root}/jsc-{domain}/` 開頭、指令裡有金錢符號或波浪號、出現三個代入點以外的大括號、代不出根目錄、代出來的腳本不在這台機器上五種(不種入等於讓上游一格填錯把一筆帶著 `last_run` 與 `fail_count` 的內建項刪掉);`{root}` 由這一支代成 `--root` 給的字面絕對根目錄(沒給就從清單位置往上推三層),那一層的目錄名以 `jsc-{domain}` 為準、找不到才退回不帶前綴的 `{domain}`,而 `{cli}` 與 `{repo}` 刻意留在值裡不展開——那兩件事種入的當下還不知道,種入時展開成多筆會讓同一個 `spec_key` 有好幾個檔案、一致化每一輪只印 `dup=`,而且 CLI 或存取庫一變就要移除再重新登錄、歷史跟著歸零;**`action` 裡出現大括號就是還沒代好的代入點,任何讀取端一律不得原樣拿去執行**,展開由往後接上來的執行那一步負責,`{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,所有目標的結果合起來算這一筆的一次成敗;`pending` 的那幾筆只印在回報裡、待辦檔上一個字都不加(寫進標題會換 `id` 又比不出漂移,另立欄位要動待辦簿那十五個固定的鍵,而理由是清單上會變的散文,抄進去就是抄一份改不掉的舊值);清單只有十一欄、也就是還沒有 `probe` 那一欄時,全部照 `way` 推 `action`、行為與加上那一欄之前一模一樣,只印一行 note 講明整份清單沒有那一欄,不逐列印警告;七個結束碼各有處置:0 對齊完成(零筆改動也算)、1 清單讀不到、2 清單讀到了卻解不出任何可交項目、3 找不到 `tasks.sh`,這三碼一律「一筆都沒動」且不得回報成清單上沒有可交項目,4 是部分失敗、逐筆帶 `tasks.sh` 的結束碼、成功的那幾筆算數,5 檔案系統失敗,6 呼叫寫錯(含 `--root` 不是絕對路徑、`--allow-cond` 形狀不對)。這一支只呼叫 `tasks.sh` 的 `list`、`add`、`remove` 三個子命令,一次都不自己動 `tasks/` 底下的檔案,也一次都不帶 `--force`。事件偵測與到期判定是本 domain 的 `tools/due.sh`,三個子命令 `scan`、`events`、`next`:`scan` 一輪一次,比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期;`events` 是唯讀預覽,一律不推進快照;`next` 是純算,給一組欄位算出 `next_run`。七個結束碼各有處置:0 判完了、1 有狀態來源存在卻讀不到(結果照樣印得出來,那個來源本輪不發事件)、2 有待辦的欄位值判不了(其餘各筆照判)、3 快照換不上去(同一批事件下一輪會被判第二次,要吵出來)、4 待辦簿目錄不存在或零筆(不是失敗,但「沒判過」不等於「都沒到期」)、5 檔案系統失敗、6 呼叫寫錯。事件靠比對狀態快照,一個產生者的腳本都不改:工作包鎖檔轉態、`sessions/{sid}.stage` 換值、`errors/hooks.jsonl` 新增列、`sessions/{sid}.start` 與 `.end`、`worklog-pending` 暫存區清空,各對一個事件名;`analyze-completed:{HASH}` 的來源在 wiki 的分析頁上,要連網才判得出來,這一輪標成未接線並吵出來,不靜靜當成還沒發生,`cron:{式子}` 同樣未接線。快照比對有一個明確的代價:**兩輪之間發生又消失的事件會漏掉**,假設「事件不會漏」就會出錯,而那種錯是無聲的。`tasks/` 底下的檔案只有 `start` 那一步的 `seed-tasks.sh apply` 會經 `tasks.sh` 動到,`patrol`、`status`、`stop` 三個操作一律只讀:`patrol` 一次都不跑 `seed-tasks.sh`(無人值守那一輪移除一筆會把那一筆的 `last_run` 與 `fail_count` 一起弄丟,而清單同步到一半就會刪錯,破壞性清理留給人),`status` 只跑 `plan`、那個子命令一律不寫。代價要講明:沒有人 `start` 也沒有人看的機器上,清單改動要等下一次 `start` 才進得了待辦簿。到期的內建項由本 domain 的 `tools/run-due.sh` 真的執行,兩個子命令 `run` 與 `plan`(`plan` 等同 `run --dry-run`,只印不跑也不回寫),由 `patrol` 那一輪在寫心跳之前叫一次,**到期清單的路徑一律用 `--rows` 餵進去、沒有預設值**(判到期那一支的輸出寫在叫它的人指定的目錄裡,留預設就會讀到別人手動跑留下的舊檔案,而舊檔案讀起來跟新的一模一樣——實測那份舊清單在機器上放了 67 小時,每一輪都被拿去動手,還跑過一筆已經被移除的待辦);清單比心跳門檻還舊就回 2 拒跑,因為比那個長度還舊的清單不可能描述這一輪;它只跑動作是一行指令、而且 `spec_key` 非空的那幾筆,動作是技能名的、只提醒的、還有使用者親手登錄的一律不跑(叫用整支技能會寫頁、開 PR、改檔案,代價與唯讀盤點差太多,要另外判一次);`{cli}` 由 `jsc-cli/tools/detect-clis.sh` 代入、一個目標跑一次、全部成功那一筆才算成功,`{repo}` 的存取庫掃描還沒做出來所以帶那個代入點的一律只印 `held=` 跳過;執行前再驗一次指令形狀(不得有金錢符號、反引號、波浪號、分號、管線與連接符號,路徑一定要落在這一輪的根目錄底下,代完之後不得留大括號),驗不過同樣只印 `held=`;**`held=` 那幾筆的 `last_run` 與 `fail_count` 一個字都不動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數蓋掉真的壞掉的那幾筆;成功回頭叫 `tasks.sh done`、下一次到期由 `due.sh next` 算,失敗回頭叫 `tasks.sh fail`,失敗照重試、不自動退讓;六個結束碼各有處置:0 這一輪處理完(零筆到期與全部 `held=` 都算)、1 有指令回非零(那一筆已記成失敗,是發現不是故障)、2 到期清單讀不到或欄位對不上因而一筆都沒跑(不得讀成「都沒到期」)、4 有回寫失敗(跑了沒記到,下一輪會再跑一次同一筆)、5 檔案系統失敗、6 呼叫寫錯,**任何一碼都不中止那一輪**。`tasks.sh` 的 `pause` 與 `resume` 兩個子命令還沒有任何一個操作呼叫得到:登錄時的補問流程、逾期與失敗的處理行為、`remind` 怎麼送到前景、待辦簿的 wiki 雙向同步,四項都還沒接上去,所以到期的那幾筆這一輪只印出來、不執行,也不回寫 `last_run`;執行那一步接上來的時候,代入點的展開歸它負責——`action` 帶大括號的那幾筆要先把 `{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,代不出目標就當這一筆這一輪沒得跑並回報,一律不得把帶大括號的字面值原樣送進殼。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | +| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-assist/tools/due.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{CURRENT}/jsc-gitea/tools/wiki-contents.sh`,連結驗證那一支是 `{CURRENT}/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `{CURRENT}/jsc-hooks/tools/report-status.sh`,範本是 `{CURRENT}/jsc-assist/templates/monitor-contents.md`;`JSC_HOME` 沒設時,人在現場那一次 `readlink` 自己退回 `~/.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` 會把安裝當下偵測到的 CLI 代號記進條目(判法照組 PATH 那一套逐支 `command -v`,不另叫外部腳本;那是往後每一輪判「有沒有少跑一支」的唯一參照基準)、會查 `{CURRENT}/jsc-assist` 與 `{CURRENT}/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把只放得下 CLI 執行檔那幾個目錄的 `PATH`(不是整條照抄——實測整條 2160 字元,crontab 一行有長度上限,照抄會回「command too long」而裝不起來;帶會過期的安裝目錄時重跑一次 `install` 就重新拍一份)、`GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(`PATH` 一定要帶:cron 給的只有 `/usr/bin` 與 `/bin`,而 CLI 執行檔多半裝在家目錄底下,少了它那一輪偵測 CLI 會回結束碼 0 加一份空清單,看起來像這台機器沒裝 CLI;巡檢要跑的指令本身走字面絕對路徑,不靠這一條)(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、刻意不寫死工作階段代號(寫死會讓那一輪的 `start` 與 `end` 落在不同的代號上:前者由 hook 從標準輸入的 JSON 讀 CLI 真正的代號,後者由工具腳本只讀得到環境變數,兩半永遠配不起來,每一輪都被算成一支中止的技能)、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `patrol_root=`、`--patrol-cmd` 或 `JSC_ASSIST_PATROL_CMD` 給的自訂指令沒帶那一段時只警告不中止、並印出這一輪要開的 `allow_rule=` 規則(七支腳本各三種呼叫形式,含 `gitea.sh`、`wiki-contents.sh`、`link-check.sh` 與 `jsc-hooks/tools/report-status.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` 上,以及 `JSC_HOME` 解不出絕對路徑(條目寫不出字面根目錄)。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort`、`rebuild` 四個子命令(`collect` 除了 `due_file=` 也轉出 `due_rows_file=`,那是機器可讀的到期清單,執行那一步要拿它當 `--rows`;`rebuild` 拿讀回來的舊頁把三塊接成要寫回去的整頁,`--old` 指舊頁檔、印 `rebuilt_file=` 與 `old_format=`;接法固定所以寫死在腳本裡,不留給呼叫端臨場挑工具——實測某一輪挑了 `awk` 被權限擋掉兩次,那一輪有人在場才繞得過去,而暫存目錄已經累積 124 個同一件事的檔案;基本資料那一塊逐字照抄,連尾端空行都不動),七個結束碼各有處置: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`、`jsc-hooks/tools/report-status.sh drain` 與 `rotate`,除了排空會把事件流的位移往前推之外全部只讀,任一項失敗不影響其餘各項。`report-status.sh` 三個結束碼各有處置:0 是排空到新事件、3 是沒有新事件(正常狀態,不是失敗)、2 是呼叫寫錯;找不到這一支、`drain` 回 0 與 3 以外的碼、或 `rotate` 回非 0,都只讓這一項標成失敗或記一筆警示,一律不中止那一輪——回報鏈自己壞掉不可以把被回報的那一輪拖下去。`rotate` 只在 `drain` 成功時緊接著跑:中間隔越久,那段時間新寫進來的事件被搬進備份檔而從此排不到的機會越大;排空失敗時位移狀態未知,這時候輪替會直接吃掉還沒排空的那一批。配對以 `session` 加 `name` 為鍵,不只看 `name`:五支 CLI 併發時同一支技能會有好幾個工作階段同時在跑。沒配對到的 `start` 留在 `$JSC_HOME/assistant/events-open.tsv` 跨輪繼續配對,開超過心跳門檻才算疑似中止,未達門檻的算還在跑,超過一天沒配對到就丟掉。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫;只有目錄頁那一個 H2 區塊例外,走 `jsc-gitea/tools/wiki-contents.sh upsert`,它自己解 `CONTENTS` 存取庫、自己讀回整頁比對標題,舊頁還是 markdown 表格時自己先整頁轉成 H2 區塊再寫,七個結束碼各有處置:0 已更新或已新增、1 組不出頁面內容或寫入失敗要 `abort`(找不到同名標題不算錯,那是附加)、2 參數錯就改正重跑(範本路徑不存在也回這一碼,代表 plugin 沒裝齊)、3 是 `CONTENTS` 存取庫未設定且**不中止這一輪**、4 是頁不存在又沒給範本,本技能一律帶第五個參數所以不會出現、7 金鑰失效要 `abort`、8 其他 API 失敗要 `abort`。比對鍵取 H2 標題,也就是內容頁頁名 `MONITOR_{HASH}`,不取「監控頁」那一條的連結:連結含 `GITEA_HOST` 與頁名的網址編碼,那三樣一變鍵就對不上,同一台機器每輪多附一個區塊;頁名只由 `{主機名}/{登入帳號}` 決定,那三樣都動不到它。連結一律寫成 `[{文字}]({絕對網址})`,網址只取 `gitea.sh wiki-url` 印的那一個、不自己組路徑,那一支的結束碼 4、5、7、8 與其餘非 0 各有處置;每一個要放進頁面的連結在寫入前先過 `jsc-gitea/tools/link-check.sh`,它每個網址印一行 `{OK|DEAD|SKIP}` 加網址加說明,五個結束碼各有處置:0 才准寫入、1 有連不到的就不寫並回報 DEAD 那幾筆、2 是一個網址都沒給要補參數重跑、3 是 `GITEA_HOST` 未設定要先設定且不得跳過驗證、7 是金鑰失效要停下來回報金鑰問題而不是當成死連結;驗證走 API 不看網頁狀態碼,私有存取庫的網頁網址對未登入請求一律回 404。頁名雜湊一律取 `gitea.sh hash-id`/`tools/hash-id` 印的完整 40 碼大寫十六進位,不截短、不加前綴、不手算,空輸入回 2。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。待辦簿的存放格式與讀寫入口是本 domain 的 `tools/tasks.sh`:一筆一檔、純文字 key=value、十五個鍵順序固定、值是空的照樣寫出那一行,讀的時候只在第一個等號斷開,寫的時候把值折成一行,一筆一檔的理由同 `restart-required.d`(並行寫入不互相覆寫),`id` 取共用 hash 規則那四十碼的前 8 碼、碰撞時每次加長兩碼,七個子命令 `list`、`add`、`done`、`fail`、`pause`、`resume`、`remove` 與八個結束碼的完整說明寫在那一支的檔頭;第十五個鍵是 `spec_key`,值是 `jsc-{domain}:{技能名}`,那是從一支技能反查到它對應那一筆內建項的唯一把手(`id` 是建立時間加標題的雜湊、反查不了;標題與 `action` 拿來當鍵會撞上使用者交辦的那幾筆),只有 `origin=assistant` 帶得上它,`--spec-key` 配 `--origin user` 回 2,`remove` 對 `origin=user` 的那一筆一律回 7、除非人親自帶 `--force`。內建定期檢查項照委派清單種入與重建的入口是本 domain 的 `tools/seed-tasks.sh`,兩個子命令 `plan`(唯讀預覽)與 `apply`(真的做),清單路徑取 `--root` 餵進來的那一個字面絕對路徑底下的 `jsc-meta/tools/delegate-spec.tsv`;欄位對映是 `trigger` 與 `recur` 原樣抄、`spec_key` 由清單前兩欄合成、`action` 由 `way` 與第十二欄 `probe` 一起推、`title` 固定寫成「委派清單內建項:{spec_key}」以免清單一改就換 `id`、`kind` 一律 `check`、`origin` 一律 `assistant`、`repo` 與 `due` 一律留空,清單的 `verdict`、`slice`、`human`、`next`、`version` 與它自己的 `origin` 欄一律不抄(清單的 `origin` 是 `seed` 或 `judged`,與待辦簿的 `origin` 同名不同義);`action` 那一欄的推法分兩路:`way` 含 `invoke` 就取技能名、`probe` 連看都不看(填了指令會讓整支交出變成只跑一支腳本,那支技能該寫的頁一頁都不會寫,所以那是清單填錯,照 `way` 取技能名並印一行 `probe_bad=`),其餘那幾種交出方式照 `probe` 走——一行指令就取那一行指令、`pending:{理由}` 取 `remind` 並印一行 `pending=`、減號或空的取 `remind`;`probe` 代不進去一律退回 `remind` 並印一行 `probe_bad=`,照樣種入那一筆,涵蓋路徑不是 `{root}/jsc-{domain}/` 開頭、指令裡有金錢符號或波浪號、出現三個代入點以外的大括號、代不出根目錄、代出來的腳本不在這台機器上五種(不種入等於讓上游一格填錯把一筆帶著 `last_run` 與 `fail_count` 的內建項刪掉);`{root}` 由這一支代成 `--root` 給的字面絕對根目錄(沒給就從清單位置往上推三層),那一層的目錄名以 `jsc-{domain}` 為準、找不到才退回不帶前綴的 `{domain}`,而 `{cli}` 與 `{repo}` 刻意留在值裡不展開——那兩件事種入的當下還不知道,種入時展開成多筆會讓同一個 `spec_key` 有好幾個檔案、一致化每一輪只印 `dup=`,而且 CLI 或存取庫一變就要移除再重新登錄、歷史跟著歸零;**`action` 裡出現大括號就是還沒代好的代入點,任何讀取端一律不得原樣拿去執行**,展開由往後接上來的執行那一步負責,`{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,所有目標的結果合起來算這一筆的一次成敗;`pending` 的那幾筆只印在回報裡、待辦檔上一個字都不加(寫進標題會換 `id` 又比不出漂移,另立欄位要動待辦簿那十五個固定的鍵,而理由是清單上會變的散文,抄進去就是抄一份改不掉的舊值);清單只有十一欄、也就是還沒有 `probe` 那一欄時,全部照 `way` 推 `action`、行為與加上那一欄之前一模一樣,只印一行 note 講明整份清單沒有那一欄,不逐列印警告;七個結束碼各有處置:0 對齊完成(零筆改動也算)、1 清單讀不到、2 清單讀到了卻解不出任何可交項目、3 找不到 `tasks.sh`,這三碼一律「一筆都沒動」且不得回報成清單上沒有可交項目,4 是部分失敗、逐筆帶 `tasks.sh` 的結束碼、成功的那幾筆算數,5 檔案系統失敗,6 呼叫寫錯(含 `--root` 不是絕對路徑、`--allow-cond` 形狀不對)。這一支只呼叫 `tasks.sh` 的 `list`、`add`、`remove` 三個子命令,一次都不自己動 `tasks/` 底下的檔案,也一次都不帶 `--force`。事件偵測與到期判定是本 domain 的 `tools/due.sh`,三個子命令 `scan`、`events`、`next`:`scan` 一輪一次,比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期;`events` 是唯讀預覽,一律不推進快照;`next` 是純算,給一組欄位算出 `next_run`。七個結束碼各有處置:0 判完了、1 有狀態來源存在卻讀不到(結果照樣印得出來,那個來源本輪不發事件)、2 有待辦的欄位值判不了(其餘各筆照判)、3 快照換不上去(同一批事件下一輪會被判第二次,要吵出來)、4 待辦簿目錄不存在或零筆(不是失敗,但「沒判過」不等於「都沒到期」)、5 檔案系統失敗、6 呼叫寫錯。事件靠比對狀態快照,一個產生者的腳本都不改:工作包鎖檔轉態、`sessions/{sid}.stage` 換值、`errors/hooks.jsonl` 新增列、`sessions/{sid}.start` 與 `.end`、`worklog-pending` 暫存區清空,各對一個事件名;`analyze-completed:{HASH}` 的來源在 wiki 的分析頁上,要連網才判得出來,這一輪標成未接線並吵出來,不靜靜當成還沒發生,`cron:{式子}` 同樣未接線。快照比對有一個明確的代價:**兩輪之間發生又消失的事件會漏掉**,假設「事件不會漏」就會出錯,而那種錯是無聲的。`tasks/` 底下的檔案只有 `start` 那一步的 `seed-tasks.sh apply` 會經 `tasks.sh` 動到,`patrol`、`status`、`stop` 三個操作一律只讀:`patrol` 一次都不跑 `seed-tasks.sh`(無人值守那一輪移除一筆會把那一筆的 `last_run` 與 `fail_count` 一起弄丟,而清單同步到一半就會刪錯,破壞性清理留給人),`status` 只跑 `plan`、那個子命令一律不寫。代價要講明:沒有人 `start` 也沒有人看的機器上,清單改動要等下一次 `start` 才進得了待辦簿。到期的內建項由本 domain 的 `tools/run-due.sh` 真的執行,兩個子命令 `run` 與 `plan`(`plan` 等同 `run --dry-run`,只印不跑也不回寫),由 `patrol` 那一輪在寫心跳之前叫一次,**到期清單的路徑一律用 `--rows` 餵進去、沒有預設值**(判到期那一支的輸出寫在叫它的人指定的目錄裡,留預設就會讀到別人手動跑留下的舊檔案,而舊檔案讀起來跟新的一模一樣——實測那份舊清單在機器上放了 67 小時,每一輪都被拿去動手,還跑過一筆已經被移除的待辦);清單比心跳門檻還舊就回 2 拒跑,因為比那個長度還舊的清單不可能描述這一輪;另外把偵測到的 CLI 跟排程條目安裝當下記下的那一份比對,少了哪一支就印 `cli_missing=` 並點名(偵測純看 PATH,分不出「裝了但執行檔不在 PATH 上」,而條目的 PATH 是安裝當下拍的,有些 CLI 裝在會過期的目錄底下——沒有基準的話那一輪照樣全部成功、照樣回報都跑過,實測報過「對 4 支都跑過」而機器上裝了 5 支);只報不修;它只跑動作是一行指令、而且 `spec_key` 非空的那幾筆,動作是技能名的、只提醒的、還有使用者親手登錄的一律不跑(叫用整支技能會寫頁、開 PR、改檔案,代價與唯讀盤點差太多,要另外判一次);`{cli}` 由 `jsc-cli/tools/detect-clis.sh` 代入、一個目標跑一次、全部成功那一筆才算成功,`{repo}` 的存取庫掃描還沒做出來所以帶那個代入點的一律只印 `held=` 跳過;執行前再驗一次指令形狀(不得有金錢符號、反引號、波浪號、分號、管線與連接符號,路徑一定要落在這一輪的根目錄底下,代完之後不得留大括號),驗不過同樣只印 `held=`;**`held=` 那幾筆的 `last_run` 與 `fail_count` 一個字都不動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數蓋掉真的壞掉的那幾筆;成功回頭叫 `tasks.sh done`、下一次到期由 `due.sh next` 算,失敗回頭叫 `tasks.sh fail`,失敗照重試、不自動退讓;六個結束碼各有處置:0 這一輪處理完(零筆到期與全部 `held=` 都算)、1 有指令回非零(那一筆已記成失敗,是發現不是故障)、2 到期清單讀不到或欄位對不上因而一筆都沒跑(不得讀成「都沒到期」)、4 有回寫失敗(跑了沒記到,下一輪會再跑一次同一筆)、5 檔案系統失敗、6 呼叫寫錯,**任何一碼都不中止那一輪**。`tasks.sh` 的 `pause` 與 `resume` 兩個子命令還沒有任何一個操作呼叫得到:登錄時的補問流程、逾期與失敗的處理行為、`remind` 怎麼送到前景、待辦簿的 wiki 雙向同步,四項都還沒接上去,所以到期的那幾筆這一輪只印出來、不執行,也不回寫 `last_run`;執行那一步接上來的時候,代入點的展開歸它負責——`action` 帶大括號的那幾筆要先把 `{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,代不出目標就當這一筆這一輪沒得跑並回報,一律不得把帶大括號的字面值原樣送進殼。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | | 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解出來的要是一條存在的絕對路徑(同一步用 `[ -d ]` 查過),解不出來或目錄不存在就回報缺 `current` 並中止,同樣取 `aborted`。`start` 要先跑過一次 `seed-tasks.sh apply` 並把它的結束碼、`added=`、`removed=`、`kept=`、`drift=`、`held=`、`bad=`、`probe=`、`pending=`、`probe_bad=` 各數字與逐列 `held=`、`bad=`、`drift=`、`dup=`、`skip_user=`、`probe=`、`pending=`、`probe_bad=` 記進收尾回報(`pending=` 那幾筆要寫成「有唯讀盤點入口、還沒接上」並附清單上的理由原文,不得跟本來就只提醒的那幾筆混成一句;`probe=` 那幾筆的 `holes=` 不是減號時要寫明還留著哪幾個代入點)(回 1、2、3 時要寫成「內建項原樣沒動」並附原因,不得寫成「沒有可交項目」),然後要那一輪巡檢的 `finish` 回 0 且 `report` 回 `state=fresh`,才算啟動成功;巡檢沒寫成心跳一律回報失敗並停下,不得宣稱啟動;`schedule.sh install patrol` 回 1 要講明條目不會被執行與 `sudo service cron start`,不得宣稱排程會定時執行;回 0 或 1 都要把 `allow_rule=` 各行、「條目含金鑰快照、變數改了要重裝」這句提醒,以及 `current` 連結缺漏的警告轉出去。`patrol` 要五項各自有 `status`、「待辦簿到期與逾期」那一節要有逐筆判定表(`due.sh` 回 0、1、2、3、4 都算判過,其中 1、2、3 各記一筆警示與一列待人處理;回別的碼就照實寫「這一輪判不出到期」並說明那不代表沒有任何一筆到期,不留空白也不寫成「都沒到期」)、執行狀態事件那一項要印出本輪事件數、非 ok 事件數與未配對的 `start`(`drain` 回 3 是沒有新事件,照樣算這一項讀到底)、監控頁那一頁要放的連結全部通過 `link-check.sh`(或整頁本來就沒有連結)、監控頁三塊重組寫成、目錄頁那一個 H2 區塊的網址通過 `link-check.sh` 後更新成功,或以目錄頁結束碼 3、或以連結驗證非 0 回報成沒更新、`finish` 回 0,才算一輪跑完;`collect` 回 4 是讓開,不算失敗也不寫任何東西;舊頁讀不回來就不寫,回報「這一輪沒有結果」;連結驗證沒過就不寫那一頁,監控頁沒寫成就 `abort`,心跳一定不寫;目錄頁除了結束碼 3 之外的非 0 也一樣 `abort`,結束碼 3 只少一筆索引,那一輪的結果已經在監控頁上,照樣寫心跳並把缺的變數列進待人處理;目錄頁那一個區塊的連結驗不過同樣只少一筆索引,照樣寫心跳並把那一筆列進待人處理。`status` 要印出現況表,或印出「助理未運行」並說明原因,並且要多印一段內建項與委派清單的差異(該加幾筆、還剩幾筆孤兒、保留的 `cond` 各是哪一支、`drift=` 各要換什麼值,以及「要套用就跑 `start`」這句話),那一段比不出來就照實說比不出來;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝、清單讀不到,五種都算正常結束。`stop` 要 `schedule.sh remove all` 先回 0、`clear` 再回 0,並印出帶三段話的停止訊息;`remove` 非 0 就回報排程還在、助理停不掉,不清心跳也不印停止訊息;`clear` 回 5 就回報心跳檔還在、助理沒有確實停掉,不印停止訊息。四個操作都要在回報之後寫一筆 `skill-end`,`status` 取 ok、blocked、failed、degraded、aborted 五選一,要與回報出去的結果一致;那一支回非 0 只回報成回報鏈的缺陷,不改寫這一次操作的成敗 | | 可驗證跡象 | 四個操作的轉錄裡,每一條指令列都是字面絕對路徑,開頭是 `/`,沒有 `$JSC_HOME`、`${JSC_HOME}` 或 `~`,也沒有任何一次因為路徑帶變數而跳出來的權限詢問;排程那一輪從頭到尾一次 `readlink`、一次 `ls` 都沒有,根目錄直接取自叫用文字;人在現場那一路才可能有 `readlink`,而且同一次叫用只出現一次。`start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照,提示文字裡有「工具根目錄=」接一個字面絕對路徑,那個值與 install 印的 `patrol_root=` 和 `allow_rule=` 用的根目錄完全相同,不是變數也不是快取實體路徑;install 印出的 `allow_rule=` 都是 current 那一組展開後的字面絕對路徑,沒有變數、沒有波浪號、沒有萬用字元,也沒有 `Write(...)`,而且 `jsc-gitea/tools/link-check.sh` 與 `jsc-hooks/tools/report-status.sh` 那三種呼叫形式都在裡面。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 只有三塊:基本資料一字未改、最新一輪換成本輪、摘要表最上面一列是本輪且總列數不超過 24,頁名的 `{HASH}` 是 40 碼大寫十六進位,雜湊來源那一列寫的是不含網域的短主機名;`CONTENTS` 存取庫裡的 `MONITOR_CONTENTS` 只有自己那一個 H2 區塊變動,同一台機器從頭到尾只有一個區塊,標題是 `MONITOR_` 接 40 碼大寫十六進位、標題上不帶連結也不帶網址,區塊裡「監控頁」那一條是 `[{頁名}]({絕對網址})` 這種連結、點下去開得起那一頁,「HASH」那一條是裸 HASH、40 碼大寫十六進位、不帶連結,八條欄位一條都不缺、格式是 `- {欄位名}:{值}`,頁上一個 markdown 表格都不剩,兩頁上點得到的連結沒有一個是死的——把頁上的網址抓出來重跑一次 `link-check.sh`,應該全部是 `OK`、結束碼 0,別台機器的區塊一字不動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `latest.md`、`summary.md`、`summary-row.md`、`newpage.md`、`contents-entry.md`,摘要列是五欄、警示來源那一欄有值或寫「無」;兩支腳本不是從 current 那一組路徑跑起來時,stderr 會有一行 `[WARN]` 點出實際路徑與應該用的路徑,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/events-prev.tsv` 換成本輪的狀態快照(三欄定位字元分隔,每一個來源另有一列 `meta`)、`$JSC_HOME/assistant/events-seen.tsv` 是每一個事件名的累計次數與最後發生時間,`$JSC_HOME/assistant/patrol/due/` 底下有本輪的 `due.md` 與 `rows.txt`(`rows.txt` 是十一欄定位字元分隔,欄位順序印在 `rows_columns=`;要逐筆取值就讀它,不要切 `task=` 那幾行,那幾行的四個欄位都可能帶空白),第一次跑那一輪的 `due.sh` 印 `first_run=1`、事件數為 0,且 `tasks/` 底下一個檔案都沒被改動,`$JSC_HOME/assistant/patrol.lock` 已經放掉;監控頁的最新一輪有「執行狀態事件」那一節,節裡有本輪事件數、非 ok 事件數,以及非 ok 明細與未配對 `start` 兩張表(一筆都沒有時寫明「沒有」,不留空表格);`$JSC_HOME/usage/scan-state/events.offset` 的數字往前推到本輪排空的位置,`$JSC_HOME/assistant/events-open.tsv` 只剩下還沒配對到 `end` 的那幾筆。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作跑完,`$JSC_HOME/usage/events.jsonl` 最後都多一筆 `name` 是 `jsc-assist:assistant`、`phase` 是 `end` 的事件,`status` 與回報出去的結果一致,而且同一個 `session` 下它與 hook 記的那一筆 `phase=start` 配得起來。`patrol`、`status`、`stop` 三個操作都不動 `tasks/` 底下的檔案;`start` 只在第一步經 `tasks.sh` 動內建項,動完之後 `tasks/` 底下每一個 `origin=user` 的檔案內容與修改時間都一字未改,`origin=assistant` 且帶 `spec_key` 的那幾筆與委派清單上非 `none`、非 `cond` 的那幾列一對一對得上(同一個 `spec_key` 只有一個檔案),`spec_key` 是空的那幾筆一筆都沒被加也沒被刪,判定是 `cond` 的那幾支在 `tasks/` 底下找不到對應檔案而回報裡逐支有一行 `held=`;`way` 只有 `patrol` 或 `remind`、而 `probe` 是一行指令的那幾筆,`action=` 那一行是完整字面絕對指令,開頭是 `/` 或一個大寫環境變數指派,路徑中段沒有版本號、沒有金錢符號、沒有波浪號,代入點只可能剩 `{cli}` 或 `{repo}`,而且那幾行在回報裡各有一行 `probe=` 對得上;`probe` 是 `pending:` 的那幾筆 `action=remind`、待辦檔上看不出跟本來就只提醒的那幾筆有什麼不同,差別只在回報裡的 `pending=` 那幾行;清單只有十一欄時 `probe=`、`pending=`、`probe_bad=` 三個數字全是 0,而且 stderr 有一行 note 講明那一份清單沒有第十二欄。四個操作都不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | diff --git a/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index bdd4c8b..68ab0c2 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -398,7 +398,7 @@ One round: read five sources, record the result, then beat. Everything before th Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran. -5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was. +5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was. 6. **Write the heartbeat.** Run `{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. diff --git a/tools/run-due.sh b/tools/run-due.sh index c6429bc..d913a3b 100755 --- a/tools/run-due.sh +++ b/tools/run-due.sh @@ -169,6 +169,23 @@ if _det=$(find_tool cli tools/detect-clis.sh); then fi N_CLI=$(awk 'END{print NR+0}' "$CLIS") +# 跟排程條目安裝當下記下的那一份比對。少了哪一支要點名,不是只印個數字。 +# +# 為什麼要比:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的 PATH 是 +# 安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——實測有一支裝在帶行程編號的多殼層目錄, +# 重開機之後那條路徑就不在了。 +# 沒有基準的後果不是報錯,是安靜地少做一支:這一輪照樣把偵測到的每一支都跑過、照樣全部 +# 成功、照樣回報「都跑過了」。實測那一輪報「對 4 支 CLI 都跑過,全部成功」,而機器上裝了 +# 5 支——報告讀起來完全正常。 +# 只報不修:那條路徑本質上是暫時的,真正的解法是那支 CLI 該裝在穩定位置,或者重跑一次 +# 排程安裝重新拍一份快照。兩件都不是這一支該替人決定的。 +CLI_MISSING='' +if [ -n "${JSC_ASSIST_CLIS:-}" ]; then + for _want in $JSC_ASSIST_CLIS; do + grep -qxF "$_want" "$CLIS" 2>/dev/null || CLI_MISSING="${CLI_MISSING:+$CLI_MISSING、}$_want" + done +fi + # {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令, # 而那一輪沒有人看得到它跑到哪裡去了。 REPO_READY=0 @@ -345,10 +362,11 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu fi done <"$ROWS" -printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s\n' \ +printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s\n' \ "$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \ - "$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" + "$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}" +[ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 CLI 反覆消失就要考慮把它裝到穩定位置。" [ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。" [ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。" [ "$RC_WRITE" -ne 0 ] && warn "有 $N_WRITE_BAD 筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆,逐筆印在上面的 write_failed= 那幾行,各自帶了 tasks.sh 的結束碼。" diff --git a/tools/schedule.sh b/tools/schedule.sh index fd0cd74..941eb49 100755 --- a/tools/schedule.sh +++ b/tools/schedule.sh @@ -381,6 +381,29 @@ cli_path() { printf '%s' "${_dirs:+$_dirs:}/usr/bin:/bin" } +# 條目要記下安裝當下偵測到哪幾支 CLI,作為往後每一輪的參照基準。 +# +# 為什麼需要這個基準:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的 +# PATH 是安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——這台機器實測就有一支裝在帶行程 +# 編號的多殼層目錄,重開機之後那條路徑就不在了。 +# 少了基準的後果不是報錯,是安靜地少做一支:那一輪照樣把偵測到的每一支都跑過、照樣全部 +# 成功、照樣回報「都跑過了」,因為它不知道該有幾支。實測那一輪報「對 4 支 CLI 都跑過, +# 全部成功」,而機器上裝了 5 支。 +# 記下來之後,讀的那一邊才有東西可比,也才答得出「該不該重跑一次 install」。 +# 判法照 cli_path() 那一套:逐支用 command -v 試,不叫外部腳本。兩個理由——這一支在 install +# 那一刻本來就已經用同一套判過一次(為了組 PATH),再叫一支等於同一件事兩套實作;而且那一支 +# 只看 PATH,跟這裡拿得到的資訊一樣多,多繞一層沒有換到任何東西。 +cli_codes() { + _codes='' + for _pair in claude:claude codex:codex copilot:copilot antigravity:agy kiro:kiro-cli; do + _code=${_pair%%:*}; _bin=${_pair#*:} + _abs=$(command -v "$_bin" 2>/dev/null) || continue + case "$_abs" in /*) ;; *) continue ;; esac + _codes="${_codes:+$_codes }$_code" + done + printf '%s' "$_codes" +} + # 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值—— # 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。 # JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁 @@ -392,7 +415,7 @@ cli_path() { # 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。 # 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。 env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值) - _p="PATH=$(shq "$(cli_path)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes" + _p="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes" _names='' for _n in $(snapshot_names); do eval "_v=\${$_n:-}" @@ -494,7 +517,7 @@ trap 'rm -rf "$TMPD"' EXIT # 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡 # 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。 PATROL_RESOLVED='' -ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes" +ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes" SNAPSHOT_NAMES='' case "$CMD:$JOBS" in install:*patrol*)