diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4645ee5..c9a1baf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.8", + "version": "0.1.9", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { @@ -19,7 +19,8 @@ "jsc-cli": ">=0.2.7", "jsc-gitea": ">=0.2.0", "jsc-hooks": ">=0.3.7", - "jsc-log": ">=0.1.4" + "jsc-log": ">=0.1.4", + "jsc-meta": ">=0.3.3" } } } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 74ba713..96df2b5 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.8", + "version": "0.1.9", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { @@ -8,7 +8,8 @@ "jsc-cli": ">=0.2.7", "jsc-gitea": ">=0.2.0", "jsc-hooks": ">=0.3.7", - "jsc-log": ">=0.1.4" + "jsc-log": ">=0.1.4", + "jsc-meta": ">=0.3.3" } } } diff --git a/plugin.json b/plugin.json index 8c1b7d1..d9ae51a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.8", + "version": "0.1.9", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { @@ -8,7 +8,8 @@ "jsc-cli": ">=0.2.7", "jsc-gitea": ">=0.2.0", "jsc-hooks": ">=0.3.7", - "jsc-log": ">=0.1.4" + "jsc-log": ">=0.1.4", + "jsc-meta": ">=0.3.3" } } } diff --git a/references/behaviors.md b/references/behaviors.md index 3c89b84..b1232d5 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -6,8 +6,8 @@ | 項目 | 內容 | | --- | --- | -| 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。執行環境健檢不走這支,走 `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` 沒設時 `readlink -f "$JSC_HOME/current"` 印的是 `/current`、結束碼 0,非空又是絕對路徑,前三項全過得了關,之後每一條字面路徑都指向不存在的地方,所以人在現場那一次要在同一步再跑 `[ -d "{剛印出來的路徑}" ]`,目錄存在才算取到根目錄;排程那一輪不查,它的根目錄是裝排程的人寫進條目的,根目錄不對就會在第一支腳本呼叫上失敗。除了人在現場那一次 `readlink`,任何指令列都不得出現 `$JSC_HOME`、`${JSC_HOME}` 或 `~`:權限層比對的是還沒展開的指令字面。實測歸納出兩條判準:一、無人值守時只有允許清單上的完整字面指令跑得動,沒有「預設安全的唯讀指令」這回事,連 `readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"` 與沒有規則的 `ls -d /root/.jsc/current` 都被擋;二、路徑中段的萬用字元不匹配,版本號寫成 `*` 的快取路徑規則一樣擋,規則與指令都必須是完整字面。排程那一輪沒有人可以按同意,被擋就是停在第一支腳本,什麼都不記,心跳也寫不出來。接著認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `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 次」、心跳與排程兜起來會誤讀的四種組合各補一句話。`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` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `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` 三個子命令,七個結束碼各有處置: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 碼、碰撞時每次加長兩碼,六個子命令與結束碼的完整說明寫在那一支的檔頭。事件偵測與到期判定是本 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/` 底下的檔案,`tasks.sh` 的 `list`、`add`、`done`、`fail`、`pause`、`resume` 六個子命令還沒有任何一個操作呼叫得到:登錄時的補問流程、逾期與失敗的處理行為、`remind` 怎麼送到前景、待辦簿的 wiki 雙向同步,四項都還沒接上去,所以到期的那幾筆這一輪只印出來、不執行,也不回寫 `last_run`。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | -| 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解出來的要是一條存在的絕對路徑(同一步用 `[ -d ]` 查過),解不出來或目錄不存在就回報缺 `current` 並中止,同樣取 `aborted`。`start` 要那一輪巡檢的 `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` 要印出現況表,或印出「助理未運行」並說明原因;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝,四種都算正常結束。`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` 配得起來。四個操作都不動 `tasks/` 底下的檔案,也不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | +| 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `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` 沒設時 `readlink -f "$JSC_HOME/current"` 印的是 `/current`、結束碼 0,非空又是絕對路徑,前三項全過得了關,之後每一條字面路徑都指向不存在的地方,所以人在現場那一次要在同一步再跑 `[ -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` 變了只印 `drift=` 不改那一筆;種入與重建是同一個呼叫、冪等,所以每一次 `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` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `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` 三個子命令,七個結束碼各有處置: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` 推(含 `invoke` 就取技能名,其餘取 `remind`,因為巡檢與提醒那兩種交出方式沒有獨立入口,填技能名會讓助理把整支技能一路跑完,那正是切片交要防的事)、`title` 固定寫成「委派清單內建項:{spec_key}」以免清單一改就換 `id`、`kind` 一律 `check`、`origin` 一律 `assistant`、`repo` 與 `due` 一律留空,清單的 `verdict`、`slice`、`human`、`next`、`version` 與它自己的 `origin` 欄一律不抄(清單的 `origin` 是 `seed` 或 `judged`,與待辦簿的 `origin` 同名不同義);七個結束碼各有處置: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` 才進得了待辦簿。`tasks.sh` 的 `done`、`fail`、`pause`、`resume` 四個子命令還沒有任何一個操作呼叫得到:登錄時的補問流程、逾期與失敗的處理行為、`remind` 怎麼送到前景、待辦簿的 wiki 雙向同步,四項都還沒接上去,所以到期的那幾筆這一輪只印出來、不執行,也不回寫 `last_run`。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | +| 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解出來的要是一條存在的絕對路徑(同一步用 `[ -d ]` 查過),解不出來或目錄不存在就回報缺 `current` 並中止,同樣取 `aborted`。`start` 要先跑過一次 `seed-tasks.sh apply` 並把它的結束碼、`added=`、`removed=`、`kept=`、`drift=`、`held=`、`bad=` 各數字與逐列 `held=`、`bad=`、`drift=`、`dup=`、`skip_user=` 記進收尾回報(回 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=`。四個操作都不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | diff --git a/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index 81c645f..5aeb4c6 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -1,6 +1,6 @@ --- name: assistant -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 five independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, the heartbeat''s own report, and the status event stream that jsc-hooks/tools/report-status.sh drains and rotates, whose starts with no matching end are the only evidence an earlier skill run aborted - 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 - and upserts its MONITOR_CONTENTS entry through jsc-gitea/tools/wiki-contents.sh, which reads the separate CONTENTS wiki repo and keeps one H2 block per machine - the heading is the monitor page''s own name, the fields are bullets under it, and one of them links that page by its absolute wiki-url. Every link on either page is written as [text](URL) and is verified by jsc-gitea/tools/link-check.sh before that page is written, so a dead link stops the write instead of landing on the page. 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).' +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. Before that first round, start reconciles the built-in check items against jsc-meta''s delegation list through tools/seed-tasks.sh - seeding and rebuilding are one idempotent call, conditional rows are held back with their condition printed, and an origin user entry is never touched; status reports the same comparison through plan, which writes nothing, and a patrol round runs neither. One round reads five independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, the heartbeat''s own report, and the status event stream that jsc-hooks/tools/report-status.sh drains and rotates, whose starts with no matching end are the only evidence an earlier skill run aborted - 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 - and upserts its MONITOR_CONTENTS entry through jsc-gitea/tools/wiki-contents.sh, which reads the separate CONTENTS wiki repo and keeps one H2 block per machine - the heading is the monitor page''s own name, the fields are bullets under it, and one of them links that page by its absolute wiki-url. Every link on either page is written as [text](URL) and is verified by jsc-gitea/tools/link-check.sh before that page is written, so a dead link stops the write instead of landing on the page. 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 @@ -67,15 +67,19 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta | --- | --- | | one patrol round | `{CURRENT}/jsc-assist/tools/patrol.sh` | | the system scheduler | `{CURRENT}/jsc-assist/tools/schedule.sh` | +| the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` | +| the built-in check items, reconciled against the delegation list | `{CURRENT}/jsc-assist/tools/seed-tasks.sh` | | the heartbeat | `{CURRENT}/jsc-hooks/hooks/heartbeat.sh` | | the status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` | | the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` | | the `MONITOR_CONTENTS` entry | `{CURRENT}/jsc-gitea/tools/wiki-contents.sh` | | the link check every write depends on | `{CURRENT}/jsc-gitea/tools/link-check.sh` | +**The delegation list is read across a plugin boundary, and the same rule applies to it.** `seed-tasks.sh` reads `{CURRENT}/jsc-meta/tools/delegate-spec.tsv`, so the root goes to it as `--root {CURRENT}` — the literal absolute path step 0 already took — and it resolves nothing for itself. `jsc-meta` is declared in this plugin's `jsc.requires` for that reason; when it is not installed the script exits 1 and changes not one entry, because a list that cannot be read is indistinguishable from a list saying nothing is delegable, and acting on the second reading deletes every built-in item at once. + **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, the directory entry depends on `wiki-contents.sh` carrying one too, and both writes depend on `link-check.sh` carrying one — without them the round is refused locally, before any request leaves the machine, and the 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 seven 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`. +**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 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 `{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. @@ -127,7 +131,8 @@ The call never changes the outcome: it returns 0 even when it cannot write, and | --- | --- | --- | | `$JSC_HOME/assistant/heartbeat` | `heartbeat.sh` only, never this skill | `key=value` lines: `ts`, `pid`, `cli`, `session` | | `$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/tasks/{id}` | this skill reads it directly; every write goes through `tasks.sh` | `key=value` lines, one task per file: `id`, `created`, `kind` (`check` / `todo`), `title`, `action`, `trigger`, `recur`, `repo`, `due`, `state` (`pending` / `done` / `paused`), `last_run`, `next_run`, `fail_count`, `origin` (`user` / `assistant`), `spec_key` (`jsc-{domain}:{skill}` for a built-in item, empty for anything a person asked for) | +| `{CURRENT}/jsc-meta/tools/delegate-spec.tsv` | `seed-tasks.sh` only, read-only | the delegation list, tab-separated, one skill per row. `verdict`, `way`, `trigger` and `recur` are what decide whether a skill gets a built-in check item and on what schedule | | `$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 `latest.md`, `summary.md`, `summary-row.md`, `newpage.md` and `contents-entry.md` | | `$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 | @@ -226,6 +231,34 @@ One table for all three subcommands. Read `collect`'s codes carefully: **1 and 3 | 5 | Filesystem failure — the lock could not be created or released, a scratch file could not be written, the snapshot could not be promoted, or `heartbeat.sh write` returned non-zero | Serious. Report it loudly with the stderr text and the path. On a `finish` failure the round is recorded but unproven: say so plainly and never claim the round beat | | 6 | Usage error — an unknown subcommand, a missing `--round`, or an option with no value | A defect in the call. Correct it and run it once more; report a second exit 6 as a defect in this skill and stop | +## Built-in check items and the delegation list + +The delegation list holds one row per jsc skill and records whether that skill can be handed to the background assistant. Every row that is delegable and carries a `trigger` and a `recur` earns one `check` entry in the task book, with `origin: assistant` and `spec_key` set to `jsc-{domain}:{skill}`. `{CURRENT}/jsc-assist/tools/seed-tasks.sh` is the only thing that creates or removes those entries, and it does it by reconciling the two sides rather than by remembering whether it has run before: + +| Situation | What the reconcile does | +| --- | --- | +| The list has a delegable row, the task book has no entry for it | add one, `origin: assistant`, `spec_key` set | +| The task book has an entry whose `spec_key` is no longer a delegable row — removed from the list, or changed to `none` | remove that entry, so no orphan is left pointing at a skill nobody delegates any more | +| The entry is `origin: user` | leave it exactly as it is, always. A skill being re-judged is never a reason to delete something a person asked for | +| The row is still delegable but its `trigger`, `recur` or `way` changed | report it as `drift=` and change nothing, unless `--refresh` was passed | +| The row's `verdict` is `cond` | hold it: seed nothing, print a `held=` line carrying the condition text, unless `--allow-cond {key}` named that row | + +**Seeding and rebuilding are the same call.** There is no first-run flag and no second code path: what happens is decided by comparing the list against the task book, so the first call seeds every item and every later call is a no-op until the list actually changes. That is why `start` runs it every time rather than only once. + +**A `cond` row is held back on purpose, and the report has to say so.** The condition lives in prose in the list, so no script can evaluate it, and one of them says in as many words that its own scripts still resolve through version-carrying paths — seeding it would have the assistant invoke, every single round, something that stops on its first script call with no error, no output and a heartbeat that still looks healthy. So the default is to hold, and every held row is printed with its condition and the half that stays with a human. Never quietly drop them: a missing item nobody can account for is the failure this reporting prevents. + +## seed-tasks.sh exit codes + +| Code | Meaning | What to do | +| --- | --- | --- | +| 0 | The two sides are reconciled — `plan` printed its verdict, or `apply` made the changes. Zero changes is this code too | Carry on, and carry the `added=`, `removed=`, `kept=`, `drift=`, `held=` and `bad=` counts into the report | +| 1 | The delegation list could not be read, so **nothing was touched** | Report `jsc-meta` as missing or unreadable and say the built-in items were left exactly as they were. Never report this as "the list has no delegable skills" | +| 2 | The list was read but not one delegable row came out of it, so **nothing was touched** | Report the list itself as suspect — a half-synced or damaged list would otherwise delete every built-in item. Point at the file and stop | +| 3 | `tasks.sh` was not found, so **nothing was touched** | Report the installation as incomplete: the task book has exactly one writer and it is missing | +| 4 | At least one `add` or `remove` failed; the rest were done | Report every `add_failed=` and `remove_failed=` line with the `tasks.sh` exit code it carries, and judge each by that script's own code table | +| 5 | Filesystem failure — the scratch directory or a scratch file could not be written | Report it with the path; the reconcile could not even be computed | +| 6 | Usage error — an unknown subcommand or option, a `--root` that is not absolute, or an `--allow-cond` value that is not `jsc-{domain}:{skill}` | A defect in the call. Correct it and run it once more | + ## Boundaries The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. Four of them need saying out loud here: @@ -234,6 +267,7 @@ The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. - **A patrol round asks nothing.** It runs from cron with nobody present, so there is no one to answer and a question hangs the round. Every branch in the patrol steps below resolves without a question: a missing source is recorded as missing, an ambiguous result is recorded verbatim, and a round that cannot proceed aborts and reports. Never call `jsc-ask:ask` from `patrol`. A command that is not on the allow list is a question too — the permission prompt is one, and it is the one nobody sees — which is why step 0 hands that round its root instead of letting it resolve one. 界線 1. - **A patrol round rewrites the monitor page as three fixed blocks.** Read the old page back first; keep 本頁基本資料 as it stands, replace 最新一輪 whole, put this round's row on top of the summary table and cut it to 24; then put the whole page. The directory page is a separate write in a separate wiki repo, and `wiki-contents.sh` does it: that page keeps one H2 block per machine, and this machine's block is the only one that is updated. A page that could not be read is a page that does not get written — the summary table only survives if the old one came back. 界線 4. - **A patrol round reports; it never acts on what it found.** The 待人處理 rows name an entry point for a human. The patrol does not run that entry point, does not fix a hook, does not update a plugin and does not touch a repository. 界線 3 and 界線 6. +- **Only a human-initiated operation reconciles the built-in items; an unattended round reports the difference and stops there.** `start` runs `seed-tasks.sh apply`, because somebody asked for it and is there to read what it added and removed. `status` runs `seed-tasks.sh plan`, which writes nothing. `patrol` runs neither: removing a check entry destroys that entry's `last_run` and `fail_count` history, and 界線 5 keeps destructive cleanup with the human — a round that deletes a row at three in the morning because the list was mid-sync leaves nobody able to see that the row ever existed. The consequence is worth stating: on a machine nobody starts or inspects, a list change reaches the task book only at the next `start`. 界線 3 and 界線 5. - **`stop` clearing the heartbeat and removing the schedule is not a breach of 界線 5「不刪除狀態檔」.** That limit protects state that records work — the task book, worktrees, wiki pages — from a background process nobody is watching. The heartbeat records one fact only, "the last patrol round finished", and the schedule entry is what keeps rounds running, so a `stop` that leaves either behind leaves a lie behind. Clearing both is the whole job of `stop`, and they are the only deletions any operation here performs, both of them entries this skill installed itself. `stop` touches nothing under `tasks/`, nobody else's cron entry, no worktree and no wiki page. Do not "restore" this limit later by taking either removal out of `stop`. ## Crash exit needs no cleanup @@ -246,23 +280,27 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by ## start -`start` proves the loop works before it schedules it: one patrol round first, then the scheduled entry. It installs no daemon and writes no bare heartbeat. +`start` proves the loop works before it schedules it: the built-in items first, then one patrol round, then the scheduled entry. It installs no daemon and writes no bare heartbeat. -1. **Run one patrol round.** Follow every step of the `patrol` operation below, start to finish. This is what writes the first heartbeat — there is no shortcut past it, because a heartbeat that no round produced is exactly the lie this design removes. When that round ends without a heartbeat for any reason (`collect` exit 4, 5 or 6, an empty `hash=`, a failed write of the monitor page, a directory-entry failure other than exit 3, or `finish` exit 2, 4 or 5), the start has failed: report the round's outcome and the code, do not run step 2, and do not claim a started assistant. A round that completed with failed items (`collect` exit 1 or 3) is still a completed round — carry on to step 2 and name the failures in the closing report. Completion condition: `patrol.sh finish` exited 0, or the failure report naming the step and the code has been printed and no start was claimed. +1. **Reconcile the built-in check items against the delegation list.** Run `{CURRENT}/jsc-assist/tools/seed-tasks.sh apply --root {CURRENT}`. This comes before the round, so the round's own task-book section already shows the items this machine is supposed to be checking. Judge the result by the seed-tasks.sh exit-code table, and keep every `add=`, `remove=`, `drift=`, `held=`, `bad=`, `dup=` and `skip_user=` line plus the summary counts for the report. **Exit 1, 2 and 3 do not stop the start.** Nothing was touched in any of those cases, so the assistant still has whatever items it had before and the round is still worth running: record what the code means, put it into the closing report, and carry on to step 2. Exit 4 is the same — the entries that did get added or removed stand, and the failed ones are named. Never pass `--force` and never pass `--allow-cond` on your own initiative: the first would let this step delete something a person asked for, and the second asserts a condition only a person can check. Completion condition: the exit code and the summary counts are recorded, with every `held=` row's skill name kept for the report, or the code was recorded as "nothing was touched" and step 2 was reached anyway. -2. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported. +2. **Run one patrol round.** Follow every step of the `patrol` operation below, start to finish. This is what writes the first heartbeat — there is no shortcut past it, because a heartbeat that no round produced is exactly the lie this design removes. When that round ends without a heartbeat for any reason (`collect` exit 4, 5 or 6, an empty `hash=`, a failed write of the monitor page, a directory-entry failure other than exit 3, or `finish` exit 2, 4 or 5), the start has failed: report the round's outcome and the code, do not run step 4, and do not claim a started assistant. A round that completed with failed items (`collect` exit 1 or 3) is still a completed round — carry on to step 3 and name the failures in the closing report. Completion condition: `patrol.sh finish` exited 0, or the failure report naming the step and the code has been printed and no start was claimed. -3. **Install the patrol entry.** Run `{CURRENT}/jsc-assist/tools/schedule.sh install patrol`. Judge the result by the schedule.sh exit-code table, and keep the printed `entry=`, `ttl=`, `period=`, `legacy_removed=`, `others_kept=`, `env_snapshot=`, `patrol_root=`, every `allow_rule=` line and `service=` for the report. Exit 1 is the case to get right: the entry is installed and inert, so step 4 reports a started assistant whose heartbeat will expire, not a scheduled one. Exit 6 with a CLI executable that is not on `PATH` is the second one: nothing was installed, and the fix is to install that CLI or to pass `--patrol-cmd`, not to write a bare command name into the entry. On 2, 3, 4, 5 or 6 nothing is scheduled — report the code, say the round ran but no further round will, and do not claim the assistant will stay alive. Completion condition: the exit code is recorded, and on exit 0 the entry line, the TTL, the period, the legacy count, the surviving-entry count, the snapshotted variable names, the tool root the entry carries and the allow rules are recorded with it. +3. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported. -4. **Report the start.** Print the round's verdict and its four item results, the monitor page that was written, the heartbeat path, the local time of `ts`, the TTL in seconds, `pid`, `cli` and `session` as hints, then the scheduler mechanism, the derived period, the installed entry line as the script printed it with the token already masked, the `patrol_root=` the entry carries — that is what every later round reads its tool root from — how many legacy heartbeat entries were removed, and how many other entries were left untouched. Then hand over the two operator items the install printed: the `allow_rule=` lines verbatim, so the unattended round never meets a permission prompt, and the reminder that the entry holds a snapshot of the listed variables including the token — keep the crontab file readable by its owner alone, and run `install` again after any of those variables changes. Close with the notice that matches step 3's outcome, printed literally with `{ttl}` replaced by the TTL just read and `{period}` by the derived period: +4. **Install the patrol entry.** Run `{CURRENT}/jsc-assist/tools/schedule.sh install patrol`. Judge the result by the schedule.sh exit-code table, and keep the printed `entry=`, `ttl=`, `period=`, `legacy_removed=`, `others_kept=`, `env_snapshot=`, `patrol_root=`, every `allow_rule=` line and `service=` for the report. Exit 1 is the case to get right: the entry is installed and inert, so step 5 reports a started assistant whose heartbeat will expire, not a scheduled one. Exit 6 with a CLI executable that is not on `PATH` is the second one: nothing was installed, and the fix is to install that CLI or to pass `--patrol-cmd`, not to write a bare command name into the entry. On 2, 3, 4, 5 or 6 nothing is scheduled — report the code, say the round ran but no further round will, and do not claim the assistant will stay alive. Completion condition: the exit code is recorded, and on exit 0 the entry line, the TTL, the period, the legacy count, the surviving-entry count, the snapshotted variable names, the tool root the entry carries and the allow rules are recorded with it. - | Step 3 | Notice | +5. **Report the start.** Print the round's verdict and its four item results, the monitor page that was written, the heartbeat path, the local time of `ts`, the TTL in seconds, `pid`, `cli` and `session` as hints, then the scheduler mechanism, the derived period, the installed entry line as the script printed it with the token already masked, the `patrol_root=` the entry carries — that is what every later round reads its tool root from — how many legacy heartbeat entries were removed, and how many other entries were left untouched. Then hand over the two operator items the install printed: the `allow_rule=` lines verbatim, so the unattended round never meets a permission prompt, and the reminder that the entry holds a snapshot of the listed variables including the token — keep the crontab file readable by its owner alone, and run `install` again after any of those variables changes. + + **Then report step 1's reconcile in its own block**, because it is the only place the built-in items are accounted for: how many were added, how many removed, how many left alone, then every held `cond` row by name with the reason it was held, every `bad=` row as a defect in the list rather than in this machine, every `drift=` row with the change the list now asks for and the note that `--refresh` is what applies it, and every `dup=` or `skip_user=` row as an entry a person has to settle. An exit of 1, 2 or 3 is reported here as "the built-in items were left as they were" with the reason, never as "there is nothing to check". Close with the notice that matches step 4's outcome, printed literally with `{ttl}` replaced by the TTL just read and `{period}` by the derived period: + + | Step 4 | Notice | | --- | --- | | exit 0 | 助理已啟動,第一輪巡檢跑完了,結果寫上監控頁了,心跳也寫了。排程接上了,之後每 {period} 分鐘跑一輪,每一輪跑完才寫一次心跳。心跳新鮮代表上一輪巡檢真的做完了;那一輪各項有沒有全過,看監控頁的本輪判定。 | | exit 1 | 助理已啟動,第一輪巡檢跑完了,排程條目也寫進去了,但 cron 服務沒在跑,那一筆一次都不會被執行。心跳過了 {ttl} 秒就會過期。請先跑 `sudo service cron start`,重開 WSL 之後要再跑一次。 | | 其他結束碼 | 助理已啟動,第一輪巡檢跑完了,但排程沒接上(結束碼 {code})。不會再有下一輪,心跳過了 {ttl} 秒就會過期,屆時請再跑一次 start。 | - Completion condition: the report carries the round verdict, the monitor page name, the path, the local heartbeat time, the TTL, the period, the three hint fields, the scheduler outcome, the tool root the entry carries, the allow rules and the snapshot reminder, and exactly one notice above appears with the real numbers. + Completion condition: the report carries the round verdict, the monitor page name, the path, the local heartbeat time, the TTL, the period, the three hint fields, the scheduler outcome, the tool root the entry carries, the allow rules and the snapshot reminder; the reconcile block carries the three counts and one line per held, drifted, rejected or duplicated row; and exactly one notice above appears with the real numbers. ## patrol @@ -324,7 +362,7 @@ One round: read five sources, record the result, then beat. Everything before th ## status -Read-only throughout. This operation creates, modifies and deletes nothing under `$JSC_HOME`, and it never calls `write` or `clear`. +Read-only throughout. This operation creates, modifies and deletes nothing under `$JSC_HOME`, and it never calls `write` or `clear`. It compares the built-in items against the delegation list with `seed-tasks.sh plan`, never with `apply`. 1. **Read the heartbeat through the script.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and split the line on spaces, taking `file=` last so a path containing spaces stays intact. Map `state=` to the verdict: `fresh` → `新鮮`, `stale` → `過期`, `invalid` → `心跳檔損壞`, `absent` → `不存在`. Print `助理未運行` for `stale`, `invalid` and `absent`. Never re-derive the verdict from `ts` yourself, and never treat `invalid` as fresh. On exit 2 or 6, follow that code's row, record the heartbeat state as unknown, and carry on to step 2 — the task book is still worth printing. Completion condition: the heartbeat state holds one of `新鮮`, `過期`, `心跳檔損壞`, `不存在` or unknown, and `ts`, `age`, `ttl`, `pid`, `cli`, `session` and `file` are recorded as read or as empty. @@ -356,7 +394,9 @@ Read-only throughout. This operation creates, modifies and deletes nothing under 6. **Flag the repeatedly failing tasks.** Append 已連續失敗 N 次 to every row whose `fail_count` is above 0, with `N` taken verbatim from the file. A broken entry that retries every round with nobody noticing is the reason this field exists, so let no such row leave the table unmarked. Completion condition: every row with `fail_count` above 0 carries the marker and its number matches the file. -7. **Finish successfully.** `助理未運行`, an absent `tasks/` directory, an empty `tasks/` directory and an uninstalled schedule are normal results — never exit non-zero for any of them. Reserve a failure report for a condition none of the tables above covers, and state which path and which error produced it. Completion condition: the report is printed and nothing under `$JSC_HOME` has been created, modified or deleted. +7. **Check the built-in items against the delegation list, read-only.** Run `{CURRENT}/jsc-assist/tools/seed-tasks.sh plan --root {CURRENT}`. `plan` writes nothing at all — it prints what a reconcile would do and stops — which is what makes it safe here, and `apply` must never be run from `status`. Judge the code by the seed-tasks.sh table and report the difference: the count of items the list expects but the task book lacks, the count of orphans the task book still holds, every `held=` row by name, and every `drift=` row with the change the list asks for. Say plainly that `start` is what applies any of it. On exit 1, 2 or 3 report that the comparison could not be made and why, and never present that as an aligned task book. Completion condition: the difference is reported with its counts and the held rows named, or the reason it could not be computed is reported, and nothing under `$JSC_HOME` was written. + +8. **Finish successfully.** `助理未運行`, an absent `tasks/` directory, an empty `tasks/` directory and an uninstalled schedule are normal results — never exit non-zero for any of them. Reserve a failure report for a condition none of the tables above covers, and state which path and which error produced it. Completion condition: the report is printed and nothing under `$JSC_HOME` has been created, modified or deleted. ## stop diff --git a/tools/seed-tasks.sh b/tools/seed-tasks.sh new file mode 100755 index 0000000..c5740f9 --- /dev/null +++ b/tools/seed-tasks.sh @@ -0,0 +1,503 @@ +#!/usr/bin/env sh +# seed-tasks.sh — 依委派清單種入與重建助理的內建定期檢查項(供 jsc-assist:assistant 呼叫)。 +# +# 用法: +# seed-tasks.sh plan [--root {字面絕對路徑}] [--spec {清單檔}] +# [--allow-cond jsc-{domain}:{技能名}]... [--refresh] +# seed-tasks.sh apply [--root {字面絕對路徑}] [--spec {清單檔}] +# [--allow-cond jsc-{domain}:{技能名}]... [--refresh] +# +# plan 唯讀預覽:算出該加哪幾筆、該移除哪幾筆、哪幾筆對不上,一筆都不寫。 +# apply 真的做:該加的用 tasks.sh add 加,該移除的用 tasks.sh remove 移除。 +# +# 結束碼: +# 0 對齊完成。plan 印完,或 apply 做完。零筆改動照樣是 0:清單與待辦簿本來就一致, +# 不是失敗,也不必分流 +# 1 委派清單讀不到:找不到那個檔案,或檔案在卻讀不到內容。**一筆都不動** +# 2 清單讀到了,卻解不出任何一列可交項目。**一筆都不動**,理由見下面「空清單一律不動手」 +# 3 找不到本 domain 的 tasks.sh。**一筆都不動**:待辦簿只有那一支寫得出來 +# 4 至少一筆 add 或 remove 失敗,其餘各筆照做完。哪一筆失敗、回了哪一碼,逐筆印出來 +# 5 檔案系統失敗:暫存目錄建不起來,或暫存檔寫不進去 +# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、--allow-cond 的值形狀不對 +# +# --- 這一支負責什麼、不負責什麼 --- +# +# 只負責「待辦簿裡的內建項要跟委派清單一致」這一件事。它不判到期(那是 due.sh)、不執行任何 +# 一筆待辦、不寫待辦檔(那是 tasks.sh,那一支是唯一的寫入入口,這裡一律去呼叫它,不自己動 +# tasks/ 底下的檔案)。 +# 種入與重建是同一段程式,不是兩段。理由:兩段程式各自回答「待辦簿裡該有哪幾筆」這個同一個 +# 問題,第一次答案相同看起來沒事,等其中一段改了判準,兩段就會一邊加一邊刪同一筆,每一輪 +# 反覆。一段程式做到冪等,第一次跑就是種入、之後每一次跑都是重建,行為只有一種。 +# 所以這一支不記「跑過沒有」。要不要動手完全由現況決定:清單上有、待辦簿沒有就加;待辦簿有、 +# 清單上沒有就移除;兩邊都有就留著不動。第二次跑因此什麼都不會加。 +# +# --- 誰是同一筆:靠 spec_key,不靠 id 也不靠標題 --- +# +# 待辦簿的 id 是「建立時間加標題」的雜湊,跟技能名沒有關係,所以反查不了。反查鍵是待辦檔裡 +# 的 spec_key 欄位,值是 jsc-{domain}:{技能名},由 tasks.sh add 的 --spec-key 寫進去。 +# 完整理由寫在 tasks.sh 的檔頭「spec_key 為什麼非有不可」,這裡只記結論:拿標題當鍵會讓 +# 標題的措辭變成介面,拿 action 當鍵會讓提醒類的那十幾筆全部撞在一起,也會撞上使用者自己 +# 交辦、動作剛好是同一支技能的那一筆。 +# +# --- 哪些欄位從清單來、哪些自己補 --- +# +# 清單有十一欄,待辦簿有十五個鍵,對得上的只有兩欄。逐個交代: +# 從清單直接抄過來 +# trigger 原樣抄。第一次什麼時候到期是判定結果,不是這一支的決定 +# recur 原樣抄。同上 +# 從清單推出來 +# spec_key domain 與 name 兩欄合起來寫成 jsc-{domain}:{技能名},那就是一支技能的身分 +# action 由 way 推,對映見下一段。way 與 action 不是同一件事 +# title 固定寫成「委派清單內建項:{spec_key}」。標題刻意不含 way、trigger、recur: +# 那幾欄會隨清單改動而變,寫進標題就等於每一次改動都換一個 id,而 id 一換, +# last_run 與 fail_count 的歷史就跟著斷掉 +# 這一支自己補,清單裡沒有對應欄位 +# kind 一律 check。清單管的是「定期做的事」,交辦事項是使用者當面給的,不從清單來 +# origin 一律 assistant。這一欄決定重建時動不動它,所以不能空、也不能是 user +# repo 一律留空。這幾項是機器層級的檢查,不綁單一存取庫;要掃存取庫的那幾項由助理 +# 自己掃工作目錄底下所有存取庫,不逐筆綁路徑 +# due 一律留空。清單沒有截止時間這一欄,補一個猜出來的截止時間會讓監控頁標出一批 +# 沒有人約定過的逾期。留空是合法狀態,意思是沒有截止時間 +# 不抄過來的清單欄位 +# verdict 只用來決定要不要種入,見下面「條件式交的那幾支一律不種入」 +# slice、human 是給人讀的判定說明,逐字抄進標題會讓標題長到在狀態表上看不完;要看它們 +# 就去看清單本身 +# next 跑完之後建議接哪一支,那是建議下一個指令那一項要用的資料,跟排程無關 +# version、origin 清單自己的稽核欄位。清單的 origin 是 seed 或 judged,講的是「這一列 +# 判定過沒有」;待辦簿的 origin 是 user 或 assistant,講的是「這一筆誰交辦的」。 +# 兩個同名不同義,一律不互抄 +# state、last_run、next_run、fail_count 由 tasks.sh 與判到期那一邊維護,這一支不碰。 +# +# --- way 與 action 的對映 --- +# +# 清單的 way 是交出方式,三種:invoke 觸發、patrol 巡檢、remind 提醒。待辦簿的 action 是 +# 助理實際要跑的事,三種:技能名、腳本、remind。兩套詞彙不對應,所以要明寫對映: +# way 含 invoke → action 取技能名。觸發的定義就是呼叫既有技能、內容照那支技能自己的 +# 流程走,所以填技能名就是照判定結果做 +# 其餘(patrol、remind、patrol,remind)→ action 取 remind +# 第二條的理由要講清楚,因為它看起來像偷懶。巡檢那個交出方式的意思是「助理在自己那一輪裡 +# 順手做那一段唯讀盤點」,而那一段沒有獨立的入口:巡檢那一輪讀的是固定那幾項來源,沒有 +# 一支腳本或一個技能名代表得了「某一支技能的唯讀切片」。這時候把技能名填進 action,助理下一輪 +# 就會去呼叫整支技能,那正是切片交要防的事——留在人手上的那一半會被一路跑完。 +# 所以填 remind:助理照時程提醒該做那一段、指出入口,不動手。等哪一天那些切片各自有了自己的 +# 入口,改的是這個對映,不是待辦簿的格式。 +# +# --- 條件式交的那幾支一律不種入 --- +# +# 判定是 cond 的那幾列,預設一筆都不種入,並且逐列印一行 held=,把清單上的條件原文帶出來。 +# 理由是條件本身是散文,程式判不了。清單裡沒有一個機器讀得懂的條件欄位,只有 slice 與 human +# 兩欄的說明文字,所以「條件成立了沒有」這件事現在只有人答得出來。 +# 預設種入的代價已經有現成的例子:其中一支健檢技能的條件明寫「只有在它自家路徑不再帶版本號 +# 之後才可以交」,而那個條件現在不成立——真的種進去,助理每一輪都會去叫它,那一輪會停在 +# 第一支自家腳本上,因為帶版本號的路徑進不了允許清單,而且是無聲的:沒有錯誤、沒有輸出, +# 心跳照寫,看起來完全正常。一個會無聲卡死的項目比一個缺掉的項目難查得多。 +# 預設不種入的代價是「少了它,看不出為什麼」,那個代價用 held= 那幾行補掉:每一列印出技能名、 +# 判定、條件原文與留在人手上的那一半,摘要另外印 held= 的筆數。少掉的那幾支看得見、也看得出 +# 是刻意少的,不是漏的。 +# 人確認過某一支的條件成立了,就帶 --allow-cond jsc-{domain}:{技能名} 種入那一支,一支一支帶, +# 不給一個「全部放行」的旗標:全部放行等於用一個決定蓋掉三個各自不同的條件。 +# +# --- 清單改了 trigger、recur 或 way 怎麼辦 --- +# +# 規格的重建規則只有三條:清單新增可交項目就加一筆、清單移除或改成不交就移除那一筆、 +# origin=user 的一律不動。一支技能還在清單上、還是可交,只是 trigger、recur 或 way 換了值, +# 三條規則一條都沒講到。這一支的處置是:**預設只印 drift= 報出來,不改那一筆。** +# 兩個理由。一、tasks.sh 刻意沒有 edit 操作,改欄位值只能移除再重新登錄,而重新登錄會換一個 +# 新的 id,last_run 與 fail_count 從此歸零——一個原本已經連續失敗五次的項目會看起來像全新的, +# 沒有人會知道它壞了。二、規格沒講的事不自己補一條規則進去,尤其是會弄丟資料的那一種。 +# 印出來,人看得到,要不要換由人決定。 +# 人決定要換就帶 --refresh:那一筆先 remove 再 add,並且明說歷史會歸零。 +# +# --- 空清單一律不動手 --- +# +# 清單讀不到(回 1)或讀到了卻解不出任何一列可交項目(回 2),這一支一筆都不移除,直接回報。 +# 這一條是刻意寫的例外,不是漏掉:一致化的邏輯照字面跑,「清單上沒有」就等於「該移除」, +# 於是一個沒裝 jsc-meta 的機器、一個 clone 到一半的存取庫、一個被改壞的清單檔,都會讓這一支 +# 把所有內建項一次刪光,而且回 0 看起來完全成功。刪光之後助理就再也不做任何定期檢查了, +# 而待辦簿上什麼都沒有,看不出曾經有過。 +# 所以「清單是空的」與「清單說沒有可交項目」在這裡當成兩件事:前者是讀取失敗,一律不動手。 +# +# --- 使用者交辦的那幾筆 --- +# +# origin=user 的待辦一律不動,這一支連讀都只讀不比對:不加、不移除、不算進 drift。 +# 擋在兩層。這一支自己跳過那幾筆,而 tasks.sh 的 remove 對 origin=user 一律回 7,除非人親自 +# 帶 --force——這一支一次都不帶那個旗標。手改過的檔案有可能出現 origin=user 卻帶著 spec_key +# 的組合(tasks.sh add 擋得住這個組合,手寫檔案擋不住),那幾筆印一行 skip_user= 並跳過, +# 不當成孤兒移除。 +# +# 環境變數: +# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 +# JSC_DELEGATE_SPEC 委派清單的路徑,優先於 --root 與自動搜尋 +# JSC_TASKS_SH 本 domain 的 tasks.sh 路徑,優先於自動搜尋 +set -u + +JSC_HOME_RAW="${JSC_HOME:-}" +if [ -z "$JSC_HOME_RAW" ]; then + JSC_HOME_RAW="${HOME:-}" + [ -n "$JSC_HOME_RAW" ] || { + printf '[jsc][助理內建項][ERR]:JSC_HOME 與 HOME 都沒有設定,找不到待辦簿的根目錄。這裡不猜一個路徑:猜錯就是拿另一個地方的待辦簿去對清單,於是把該有的那幾筆全部當成缺的再加一次。請設定 JSC_HOME 再跑一次。\n' >&2 + exit 6 + } + JSC_HOME_RAW="$JSC_HOME_RAW/.jsc" +fi +case "$JSC_HOME_RAW" in + /*) ;; + *) + _abs=$(CDPATH= cd -- "$JSC_HOME_RAW" 2>/dev/null && pwd -L) || _abs='' + [ -n "$_abs" ] || { + printf '[jsc][助理內建項][ERR]:JSC_HOME 是相對路徑(%s),也解不出絕對路徑。待辦簿的位置必須是字面絕對路徑,請把 JSC_HOME 設成絕對路徑再跑一次。\n' "$JSC_HOME_RAW" >&2 + exit 6 + } + JSC_HOME_RAW="$_abs" ;; +esac + +JSC_HOME="$JSC_HOME_RAW" +CURRENT="$JSC_HOME/current" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) +SCRIPT_DIR="${SCRIPT_DIR:-.}" + +TAB=$(printf '\t') + +die() { # $1=結束碼 $2=訊息 + printf '[jsc][助理內建項][ERR]:%s\n' "$2" >&2 + exit "$1" +} +warn() { printf '[jsc][助理內建項][WARN]:%s\n' "$1" >&2; } +note() { printf '[jsc][助理內建項]:%s\n' "$1" >&2; } + +usage() { + cat >&2 <<'EOF' +usage: seed-tasks.sh plan [--root 字面絕對路徑] [--spec 清單檔] + [--allow-cond jsc-{domain}:{技能名}]... [--refresh] + seed-tasks.sh apply [--root 字面絕對路徑] [--spec 清單檔] + [--allow-cond jsc-{domain}:{技能名}]... [--refresh] +EOF + exit 6 +} + +# 判準與處置同這個 domain 的其他腳本:只警告、照跑。從工作樹直接跑是開發時的正當用法。 +warn_if_not_current() { + _want="$CURRENT/jsc-assist/tools/$(basename -- "$0")" + case "$SCRIPT_DIR/" in + "$CURRENT"/*) return 0 ;; + esac + warn "這支腳本是從 $SCRIPT_DIR/$(basename -- "$0") 跑起來的,不是 $_want。權限閘門只放行 current 那一組確切路徑:無人值守那一輪用別的路徑會被靜靜擋掉。開發時這樣跑沒關係。" + return 0 +} +warn_if_not_current + +valid_spec_key() { + printf '%s' "$1" | LC_ALL=C grep -qE '^jsc-[a-z0-9-]+:[a-z0-9-]+$' +} + +# --- 找委派清單 --- + +# 根目錄優先取 --root 給的字面絕對路徑,理由與技能本文取根目錄的規則相同:無人值守那一輪 +# 自己不解根目錄,值由裝排程的人寫進條目、再一路餵下來。呼叫端沒給才自己找,找的順序比照 +# patrol.sh 的 find_tool():先環境變數覆寫,再 current 那一組連結,然後開發用的並排存取庫 +# 版面,最後已安裝的快取版面。 +find_spec() { + if [ -n "${JSC_DELEGATE_SPEC:-}" ]; then + [ -f "$JSC_DELEGATE_SPEC" ] && { printf '%s\n' "$JSC_DELEGATE_SPEC"; return 0; } + return 1 + fi + if [ -n "$OPT_SPEC" ]; then + [ -f "$OPT_SPEC" ] && { printf '%s\n' "$OPT_SPEC"; return 0; } + return 1 + fi + if [ -n "$OPT_ROOT" ]; then + for _c in "$OPT_ROOT/jsc-meta/tools/delegate-spec.tsv" "$OPT_ROOT/meta/tools/delegate-spec.tsv"; do + [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + done + return 1 + fi + for _c in "$CURRENT/jsc-meta/tools/delegate-spec.tsv" "$CURRENT/meta/tools/delegate-spec.tsv"; do + [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + done + _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" + for _c in "$_root/../meta/tools/delegate-spec.tsv" "$_root/../jsc-meta/tools/delegate-spec.tsv"; do + [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + done + _c=$(ls -d "$_root"/../../jsc-meta/*/tools/delegate-spec.tsv \ + "$_root"/../../meta/*/tools/delegate-spec.tsv \ + "$HOME"/.claude/plugins/cache/*/jsc-meta/*/tools/delegate-spec.tsv 2>/dev/null \ + | sort | tail -n1) + [ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + return 1 +} + +# tasks.sh 一律取這一支腳本旁邊那一份,不去 current 或快取裡另外挑:兩支同 domain 的腳本 +# 混到不同版本,欄位順序或結束碼一不一樣都看不出來,而寫入的是待辦簿本身。 +find_tasks_sh() { + if [ -n "${JSC_TASKS_SH:-}" ]; then + [ -f "$JSC_TASKS_SH" ] && { printf '%s\n' "$JSC_TASKS_SH"; return 0; } + return 1 + fi + [ -f "$SCRIPT_DIR/tasks.sh" ] && { printf '%s\n' "$SCRIPT_DIR/tasks.sh"; return 0; } + return 1 +} + +# --- way 對 action --- + +# 對映與理由見檔頭「way 與 action 的對映」。way 是半形逗號隔開的清單,比對時前後各補一個 +# 逗號,才不會讓 invoke 去命中一個叫別的名字但含有 invoke 這幾個字的交出方式。 +action_of() { # $1=way $2=spec_key + case ",$1," in + *,invoke,*) printf '%s' "$2" ;; + *) printf 'remind' ;; + esac +} + +# --- 參數 --- + +MODE="${1:-}" +[ -n "$MODE" ] || usage +case "$MODE" in + plan|apply) ;; + *) usage ;; +esac +shift + +OPT_ROOT='' +OPT_SPEC='' +OPT_REFRESH=0 +ALLOW_COND='' +while [ "$#" -gt 0 ]; do + case "$1" in + --root) [ "$#" -ge 2 ] || usage; OPT_ROOT="$2"; shift 2 ;; + --spec) [ "$#" -ge 2 ] || usage; OPT_SPEC="$2"; shift 2 ;; + --refresh) OPT_REFRESH=1; shift ;; + --allow-cond) + [ "$#" -ge 2 ] || usage + valid_spec_key "$2" || die 6 "--allow-cond「$2」不是 jsc-{domain}:{技能名} 這個形狀。形狀不對的值放行不了任何一支,而那一支會被當成沒放行、靜靜少掉。" + ALLOW_COND="$ALLOW_COND $2" + shift 2 ;; + *) usage ;; + esac +done + +case "$OPT_ROOT" in + ''|/*) ;; + *) die 6 "--root 給的是「$OPT_ROOT」,不是絕對路徑。根目錄由呼叫端餵進來,這一支不自己解、也不猜:相對路徑在排程那一輪等於指向排程機制的工作目錄。" ;; +esac + +allowed_cond() { # $1=spec_key + for _a in $ALLOW_COND; do + [ "$_a" = "$1" ] && return 0 + done + return 1 +} + +# --- 前置 --- + +SPEC=$(find_spec) || SPEC='' +[ -n "$SPEC" ] || die 1 "找不到委派清單(jsc-meta 的 tools/delegate-spec.tsv)。這一支一筆都不動:讀不到清單的時候,「清單上沒有」與「這台機器沒裝 jsc-meta」分不出來,照字面跑會把所有內建項一次刪光。請安裝 jsc-meta,或用 --root 指定工具根目錄、用 --spec 直接指定清單檔。" +[ -r "$SPEC" ] || die 1 "委派清單在 $SPEC,可是讀不到。一筆都不動,理由同上。請檢查那個檔案的權限。" + +TASKS_SH=$(find_tasks_sh) || TASKS_SH='' +[ -n "$TASKS_SH" ] || die 3 "找不到本 domain 的 tasks.sh(找過 $SCRIPT_DIR)。待辦簿只有那一支寫得出來,這一支不自己動 tasks/ 底下的檔案,所以這一次一筆都不動。" + +TMPD=$(mktemp -d 2>/dev/null) || die 5 '建不出暫存目錄,這一輪算不出要改哪幾筆。' +trap 'rm -rf "$TMPD"' EXIT +WANT="$TMPD/want.tsv" +HAVE="$TMPD/have.tsv" +: >"$WANT" 2>/dev/null || die 5 "暫存檔寫不進去:$WANT。" +: >"$HAVE" 2>/dev/null || die 5 "暫存檔寫不進去:$HAVE。" + +# --- 讀清單,算出「該有哪幾筆」 --- + +N_HELD=0 +N_BAD=0 +# 註解列與空白列跳掉。清單是定位字元分隔,欄位順序見清單自己的檔頭。 +while IFS="$TAB" read -r c_domain c_name c_verdict c_way c_slice c_human c_trigger c_recur c_rest; do + case "$c_domain" in + ''|'#'*) continue ;; + esac + [ -n "$c_name" ] || continue + _key="jsc-$c_domain:$c_name" + case "$c_verdict" in + none) + # 不交的那幾列本來就不該在待辦簿裡,連 held 都不算:它們是判定過決定不交,不是條件 + # 還沒成立。清單上沒有它們,重建時對應那一筆就會被移除,那正是規格要的行為。 + continue ;; + full|slice) ;; + cond) + if allowed_cond "$_key"; then + note "$_key 是條件式交,這一次由 --allow-cond 放行,照 full 與 slice 同一套種入。條件成立與否由帶這個旗標的人負責。" + else + # 放行過的那幾支不算保留:摘要的 held= 要等於「這一次少掉幾支」,把放行的也算進去, + # 那個數字就跟上面的 held= 行數對不起來。 + N_HELD=$((N_HELD + 1)) + printf 'held=%s verdict=cond way=%s\n' "$_key" "$c_way" + printf ' 條件:%s\n' "$c_slice" + printf ' 留在人手上:%s\n' "$c_human" + continue + fi ;; + *) + N_BAD=$((N_BAD + 1)) + printf 'bad=%s reason=判定欄的值是「%s」,不在 full、slice、cond、none 四個裡面\n' "$_key" "$c_verdict" + continue ;; + esac + # 清單說可交,trigger 或 recur 卻沒有值,這一筆種不出來:補一個猜出來的時程等於讓助理 + # 拿一個沒有人同意過的排程去跑。印出來讓人回去補清單。 + if [ -z "$c_trigger" ] || [ "$c_trigger" = '-' ] || [ -z "$c_recur" ] || [ "$c_recur" = '-' ]; then + N_BAD=$((N_BAD + 1)) + printf 'bad=%s reason=判定是 %s 卻沒有 trigger 或 recur(trigger=%s recur=%s),種不出來\n' \ + "$_key" "$c_verdict" "${c_trigger:--}" "${c_recur:--}" + continue + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$_key" 'check' "委派清單內建項:$_key" "$(action_of "$c_way" "$_key")" \ + "$c_trigger" "$c_recur" >>"$WANT" 2>/dev/null \ + || die 5 "暫存檔寫不進去:$WANT。" +done <"$SPEC" + +N_WANT=$(awk 'END{print NR+0}' "$WANT") +if [ "$N_WANT" -eq 0 ]; then + printf 'mode=%s spec=%s want=0 held=%s bad=%s\n' "$MODE" "$SPEC" "$N_HELD" "$N_BAD" + die 2 "委派清單 $SPEC 讀到了,卻一列可交項目都解不出來(保留 $N_HELD 列、對不上 $N_BAD 列)。一筆都不移除:一份被改壞或抄到一半的清單照字面跑會把所有內建項刪光,而那之後助理就不再做任何定期檢查,待辦簿上也看不出曾經有過。請先確認清單本身。" +fi + +# --- 讀待辦簿,算出「現在有哪幾筆」 --- + +# list 的欄位順序:id、kind、state、title、action、trigger、recur、repo、due、last_run、 +# next_run、fail_count、origin、spec_key。這裡只留帶 spec_key 的那幾筆,其餘與清單無關。 +if ! "$TASKS_SH" list --no-header >"$TMPD/list.tsv" 2>"$TMPD/list.err"; then + cat "$TMPD/list.err" >&2 + die 3 "$TASKS_SH list 回了非 0,讀不出待辦簿現況。一筆都不動:讀不到現況的時候,每一筆都會被當成缺的再加一次。" +fi +awk -F"$TAB" 'NF>=14 && $14!=""{printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",$14,$1,$13,$2,$4,$5,$6,$7}' \ + "$TMPD/list.tsv" >"$HAVE" 2>/dev/null || die 5 "暫存檔寫不進去:$HAVE。" + +N_SKIP_USER=0 +N_DUP=0 + +# --- 一致化 --- + +N_ADD=0 +N_REMOVE=0 +N_KEEP=0 +N_DRIFT=0 +RC_PARTIAL=0 + +run_add() { # $1=spec_key $2=kind $3=title $4=action $5=trigger $6=recur + if [ "$MODE" = plan ]; then + printf 'add=%s action=%s trigger=%s recur=%s title=%s(plan,沒有寫進去)\n' "$1" "$4" "$5" "$6" "$3" + N_ADD=$((N_ADD + 1)) + return 0 + fi + # 結束碼要在呼叫的下一行就接住。寫成 if 的條件再到後面讀 $?,讀到的是那個 if 整段的碼, + # 失敗那一路永遠是 0,於是每一筆失敗都會被回報成 exit=0。 + "$TASKS_SH" add --kind "$2" --title "$3" --action "$4" --trigger "$5" \ + --recur "$6" --origin assistant --spec-key "$1" >"$TMPD/add.out" 2>"$TMPD/add.err" + _rc=$? + if [ "$_rc" -eq 0 ]; then + _id=$(sed -n 's/^added=\([0-9A-Fa-f]*\).*/\1/p' "$TMPD/add.out" | head -n1) + printf 'add=%s id=%s action=%s trigger=%s recur=%s\n' "$1" "${_id:--}" "$4" "$5" "$6" + N_ADD=$((N_ADD + 1)) + return 0 + fi + cat "$TMPD/add.err" >&2 + printf 'add_failed=%s exit=%s\n' "$1" "$_rc" + RC_PARTIAL=1 + return 1 +} + +run_remove() { # $1=spec_key $2=id $3=理由 + if [ "$MODE" = plan ]; then + printf 'remove=%s id=%s reason=%s(plan,沒有刪掉)\n' "$1" "$2" "$3" + N_REMOVE=$((N_REMOVE + 1)) + return 0 + fi + # 一次都不帶 --force:那個旗標留給人。無人值守那一輪碰到 origin=user 就該被擋下。 + "$TASKS_SH" remove "$2" >"$TMPD/rm.out" 2>"$TMPD/rm.err" + _rc=$? + if [ "$_rc" -eq 0 ]; then + printf 'remove=%s id=%s reason=%s\n' "$1" "$2" "$3" + N_REMOVE=$((N_REMOVE + 1)) + return 0 + fi + cat "$TMPD/rm.err" >&2 + printf 'remove_failed=%s id=%s exit=%s\n' "$1" "$2" "$_rc" + RC_PARTIAL=1 + return 1 +} + +# 一、清單上有的,待辦簿裡有沒有。 +while IFS="$TAB" read -r w_key w_kind w_title w_action w_trigger w_recur; do + _hits=$(awk -F"$TAB" -v k="$w_key" '$1==k{n++} END{print n+0}' "$HAVE") + if [ "$_hits" -eq 0 ]; then + run_add "$w_key" "$w_kind" "$w_title" "$w_action" "$w_trigger" "$w_recur" || true + continue + fi + if [ "$_hits" -gt 1 ]; then + # 同一個鍵有兩筆以上,這一支不猜該留哪一筆:兩筆的 last_run 與 fail_count 不同,刪錯 + # 的那一筆的歷史就沒了。印出來讓人挑。 + N_DUP=$((N_DUP + 1)) + printf 'dup=%s count=%s reason=同一個清單鍵有多筆,這一支不動它,請人留一筆\n' "$w_key" "$_hits" + awk -F"$TAB" -v k="$w_key" '$1==k{printf " dup_id=%s origin=%s title=%s\n",$2,$3,$5}' "$HAVE" + continue + fi + _id=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $2}' "$HAVE") + _origin=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $3}' "$HAVE") + if [ "$_origin" != assistant ]; then + # 手改過的檔案才會出現這個組合,tasks.sh add 擋得住。一律不動,也不算孤兒。 + N_SKIP_USER=$((N_SKIP_USER + 1)) + printf 'skip_user=%s id=%s origin=%s reason=帶清單鍵但不是助理內建,一律不動\n' "$w_key" "$_id" "$_origin" + continue + fi + _h_kind=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $4}' "$HAVE") + _h_action=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $6}' "$HAVE") + _h_trigger=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $7}' "$HAVE") + _h_recur=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $8}' "$HAVE") + _diff='' + [ "$_h_kind" = "$w_kind" ] || _diff="$_diff kind:$_h_kind → $w_kind" + [ "$_h_action" = "$w_action" ] || _diff="$_diff action:$_h_action → $w_action" + [ "$_h_trigger" = "$w_trigger" ] || _diff="$_diff trigger:$_h_trigger → $w_trigger" + [ "$_h_recur" = "$w_recur" ] || _diff="$_diff recur:$_h_recur → $w_recur" + if [ -z "$_diff" ]; then + printf 'keep=%s id=%s\n' "$w_key" "$_id" + N_KEEP=$((N_KEEP + 1)) + continue + fi + N_DRIFT=$((N_DRIFT + 1)) + if [ "$OPT_REFRESH" -eq 1 ]; then + printf 'drift=%s id=%s refresh=1 diff=%s\n' "$w_key" "$_id" "$_diff" + if run_remove "$w_key" "$_id" '清單的判定結果換了值,--refresh 要求重新登錄'; then + run_add "$w_key" "$w_kind" "$w_title" "$w_action" "$w_trigger" "$w_recur" || true + fi + else + printf 'drift=%s id=%s refresh=0 diff=%s\n' "$w_key" "$_id" "$_diff" + printf ' 這一筆沒有改。要照清單換值請帶 --refresh,那一筆會先移除再重新登錄,id 會換,last_run 與 fail_count 會歸零。\n' + N_KEEP=$((N_KEEP + 1)) + fi +done <"$WANT" + +# 二、待辦簿裡有、清單上沒有的,就是孤兒。 +while IFS="$TAB" read -r h_key h_id h_origin h_kind h_title h_action h_trigger h_recur; do + [ -n "$h_key" ] || continue + if awk -F"$TAB" -v k="$h_key" '$1==k{f=1} END{exit f?0:1}' "$WANT"; then + continue + fi + if [ "$h_origin" != assistant ]; then + N_SKIP_USER=$((N_SKIP_USER + 1)) + printf 'skip_user=%s id=%s origin=%s reason=帶清單鍵但不是助理內建,一律不動\n' "$h_key" "$h_id" "$h_origin" + continue + fi + run_remove "$h_key" "$h_id" '清單上已經沒有這一支,或它改判成不交' || true +done <"$HAVE" + +# --- 摘要 --- + +N_HAVE=$(awk 'END{print NR+0}' "$HAVE") +printf 'mode=%s spec=%s tasks_sh=%s want=%s have=%s added=%s removed=%s kept=%s drift=%s held=%s bad=%s dup=%s skip_user=%s\n' \ + "$MODE" "$SPEC" "$TASKS_SH" "$N_WANT" "$N_HAVE" "$N_ADD" "$N_REMOVE" "$N_KEEP" \ + "$N_DRIFT" "$N_HELD" "$N_BAD" "$N_DUP" "$N_SKIP_USER" + +[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 支是條件式交,這一次沒有種入,逐支印在上面的 held= 那幾行。條件是散文,程式判不了;人確認過某一支的條件成立就帶 --allow-cond 那一支的鍵。" +[ "$N_BAD" -gt 0 ] && warn "清單上有 $N_BAD 列對不上,那幾支這一次沒有種入,逐列印在上面的 bad= 那幾行。請回去補清單,不要在這裡補預設值。" +[ "$N_DRIFT" -gt 0 ] && [ "$OPT_REFRESH" -eq 0 ] && note "有 $N_DRIFT 筆的判定結果與清單不一樣,這一次照原樣留著。要換值請帶 --refresh,並且知道那一筆的 last_run 與 fail_count 會歸零。" + +[ "$RC_PARTIAL" -eq 0 ] || die 4 "有 add 或 remove 失敗,其餘各筆照做完了。失敗的逐筆印在上面的 add_failed= 與 remove_failed= 那幾行,各自帶了 tasks.sh 的結束碼,照那一支的結束碼表處理。" +exit 0 diff --git a/tools/tasks.sh b/tools/tasks.sh index 8f15e23..676294a 100755 --- a/tools/tasks.sh +++ b/tools/tasks.sh @@ -7,24 +7,29 @@ # tasks.sh add --kind {check|todo} --title {一句話} --action {技能|腳本|remind} # --trigger {at:...|after:...} --recur {once|every:...|cron:...} # --origin {user|assistant} [--repo {存取庫}] [--due {ISO 時間}] -# [--dry-run] +# [--spec-key jsc-{domain}:{技能名}] [--dry-run] # tasks.sh done {id} [--last-run {ISO 時間}] [--next-run {ISO 時間}] # tasks.sh fail {id} [--last-run {ISO 時間}] # tasks.sh pause {id} # tasks.sh resume {id} +# tasks.sh remove {id} [--force] # # 結束碼: -# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了。 +# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了; +# remove 把那一個檔案刪掉了。 # pause 對已經是 paused 的那一筆、resume 對已經是 pending 的那一筆,照樣回 0:同一個 # 狀態不算轉移,擋它只會讓呼叫端為了「本來就對」的結果去分流 -# 1 指名的那一筆不存在:done、fail、pause、resume 給的 id 找不到對應檔案 +# 1 指名的那一筆不存在:done、fail、pause、resume、remove 給的 id 找不到對應檔案 # 2 欄位值不合法:必填欄位缺、值不在允許集合、事件名不在固定詞彙表、標題折完是空的、 -# id 不是十六進位、fail_count 不是非負整數 +# id 不是十六進位、fail_count 不是非負整數、spec_key 不是 jsc-{domain}:{技能名} 這個形狀, +# 或 spec_key 配上 origin=user # 3 不合法的狀態轉移,已擋下。哪些合法見下面「狀態怎麼轉」那張表 # 4 這一筆已經有了:add 算出來的完整雜湊撞上一個「建立時間與標題都相同」的既有檔案 # 5 檔案系統或雜湊失敗:待辦簿目錄建不起來、檔案寫不進去、這台機器算不出 SHA-1 # 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、缺 id,或 JSC_HOME 與 HOME 都 # 解不出絕對路徑(沒有根目錄可寫,猜一個等於把待辦簿寫到別的地方去) +# 7 remove 擋下:那一筆的 origin 是 user,而這一次沒有帶 --force。理由見下面 +# 「remove 為什麼要擋使用者交辦的那幾筆」 # # --- 這一支負責什麼、不負責什麼 --- # @@ -53,11 +58,11 @@ # # --- 存放格式:純文字 key=value,一行一欄位 --- # -# 一筆固定十四個鍵,順序固定,缺一個都不寫。十四個裡有兩個是格式自己需要的: +# 一筆固定十五個鍵,順序固定,缺一個都不寫。十五個裡有兩個是格式自己需要的: # id 檔名,也寫進檔案裡一份。只看檔名的話,檔案被複製或改名之後就對不上內容 # created 建立時間,UTC 的 ISO 時間。id 是由它與 title 算出來的,不存它就再也算不回 # 同一個 id,也就驗不出檔名對不對,碰撞時也接不下去 -# 其餘十二個是待辦本身的欄位: +# 其餘十三個是待辦本身的欄位: # kind check(定期檢查項)或 todo(交辦事項)。同一本簿、同一組欄位,只用它分 # title 一句話講完要做什麼 # action 助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手) @@ -70,11 +75,30 @@ # next_run 下一次預定執行的時間 # fail_count 連續失敗次數 # origin user(使用者交辦)或 assistant(助理內建) +# spec_key 這一筆是哪一支技能的內建項,寫成 jsc-{domain}:{技能名}。使用者交辦的一律空 # # 值是空的照樣把那一行寫出來(例如 repo=)。空值有明確的意思——沒有綁存取庫、沒有截止 # 時間、還沒跑過——所以讓每一筆的形狀都一樣,讀的人不必去分「鍵不見了」與「鍵在但是空的」, # 兩眼一比就看得出哪一欄沒填。不認得的鍵一律忽略,往後加欄位不會讓舊檔案讀不進來。 # +# --- spec_key 為什麼非有不可 --- +# +# 助理的內建檢查項是照委派清單種進來的,清單改了就要重建:清單新增一支就加一筆,一支改成 +# 不交或整列被刪掉就把對應那一筆移除。所以重建那一邊一定要能從「一支技能」反查到「待辦簿裡 +# 屬於它的那一筆」,而其他每一個欄位都反查不了: +# id 是「建立時間加標題」的雜湊,跟技能名沒有關係,算不回來也查不過去 +# title 標題是給人看的一句話。拿它當鍵,等於把標題的措辭變成介面:改一個字,舊那一筆 +# 就再也認不出來,於是每次重建都刪不掉舊的、又加一筆新的,同一個檢查每輪做兩次 +# action 交出方式是提醒的那幾筆,action 全部都是 remind 這個同一個字,一支都分不出來; +# 而交出方式是觸發的那幾筆,action 是技能名,會跟使用者自己交辦、動作剛好也是 +# 那一支技能的那一筆撞在一起——撞上就會把使用者交辦的事當成內建項刪掉 +# origin 只分得出「助理內建」與「使用者交辦」兩群,群裡是哪一支分不出來 +# 所以身分要有自己的一欄。spec_key 只放身分,不放判定結果:清單裡的 trigger、recur、way +# 各自對映到別的欄位,那幾欄會隨清單改動而變,身分不會。 +# 這一欄與 origin 是兩件事,不可以互相推導:origin=assistant 而 spec_key 是空的,代表這一筆 +# 是助理自己因為別的理由建的、不受清單管;origin=user 而帶 spec_key 一律擋下(回 2), +# 不然下一次重建就會拿清單去刪使用者交辦的事,而規格明寫那幾筆一律不動。 +# # --- 值裡有等號或換行怎麼辦 --- # # 兩條約定,合起來讓這個格式壞不了,而且不必發明跳脫規則: @@ -127,6 +151,8 @@ # paused resume pending paused 只由人設,也只有人解得開 # pending resume pending(不算轉移,回 0) # paused pause paused(不算轉移,回 0) +# 任何狀態 remove (不存在) 這一筆整個不見。三個狀態都收,理由見下面 +# 「remove 為什麼要擋使用者交辦的那幾筆」 # 被擋下的幾條,各自的理由: # paused + done 停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收 # 先 resume,讓「解開」這件事是人做的、看得到的 @@ -137,9 +163,9 @@ # 與 fail。失敗連續幾次都一樣留在 pending,靠 fail_count 讓人看到,不自動停掉——自動停掉 # 等於助理自己決定不做某件事,而且沒有人會發現。 # -# --- 為什麼是六個操作,不是四個 --- +# --- 為什麼是七個操作,不是四個 --- # -# 存放層要的是四個:list、add、done、pause。另外兩個是補洞,不是加功能: +# 存放層要的是四個:list、add、done、pause。另外三個是補洞,不是加功能: # fail last_run、next_run、fail_count 三個欄位由助理自己維護、不由人填,但四個操作裡 # 沒有一個寫得到 fail_count。少了它,fail_count 永遠是 0,監控頁與提醒上的 # 「已連續失敗 N 次」就永遠是 0 次,於是一個壞掉的項目每輪重試而沒有人知道—— @@ -147,11 +173,30 @@ # resume paused 只由人設,也就只有人解得開,沒有別的元件寫得出這個轉移。只給 pause # 不給 resume,pause 就是一道單向門:停掉的那一筆再也回不來,人只能去手改檔案, # 而手改檔案繞過了上面那張轉移表。 +# remove 規格要求「清單移除或改成不交,待辦簿移除對應那筆,不留孤兒」,而六個操作裡沒有 +# 一個刪得掉一筆。少了它,重建那一邊只剩兩條路:把孤兒留著,於是助理會去跑一支 +# 判過不交、甚至已經被刪掉的技能,失敗還不會自動暫停,一路重試;或者自己去 rm +# 那個檔案,而這一支的檔頭明寫它是唯一寫得出待辦檔的入口,繞過去之後上面那張 +# 轉移表與碰撞規則就只約束得到一半的寫入者。所以刪除要走同一個入口。 # last_run 與 next_run 不另開操作:done 與 fail 都吃 --last-run 與 --next-run,值由呼叫端 # 算好餵進來。這一支不算下一次是什麼時候,算的邏輯在別的地方,兩邊各算一次就會漂移。 # 沒有 edit 操作。改欄位值要重新登錄一筆,理由是 id 由 created 與 title 算出來,改掉標題 # 之後 id 就對不回去了,留一個算不回來的 id 比多一筆待辦糟。 # +# --- remove 為什麼要擋使用者交辦的那幾筆 --- +# +# remove 是這一支唯一真的會弄丟資料的操作,而它的主要呼叫端是無人值守那一輪的清單重建。 +# 規格對那一輪的規定只有一條:origin=user 的項目一律不動——助理不會因為一支技能改判就把 +# 使用者交辦的事刪掉。那條規定寫在呼叫端,可是刪除只有這一個入口,所以擋在這裡最省: +# 呼叫端每多一個,那條規定就要各自再實作一次,漏掉一個就刪掉一件沒有人同意刪的事,而且是 +# 在沒有人看的時候刪的,事後也查不出來是誰刪的。 +# 擋法是回 7 並且什麼都不動,不是靜靜跳過:靜靜跳過會讓呼叫端以為刪掉了,於是它下一步就 +# 去補一筆新的,最後兩筆並存。 +# --force 留給人:使用者要刪自己交辦的那一筆是正當的,那一次有人在現場。無人值守那一輪 +# 一律不帶這個旗標。 +# 三個狀態都收得下 remove,包含 paused:那不是狀態轉移,是這一筆整個不再存在。擋 paused +# 反而會讓一支已經刪掉的技能留下一筆永遠刪不掉的孤兒,只因為有人先按了暫停。 +# # 環境變數: # JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 # JSC_HASH_ID 共用 hash 規則那一支的路徑,優先於自動搜尋 @@ -203,14 +248,17 @@ warn() { printf '[jsc][助理待辦簿][WARN]:%s\n' "$1" >&2; } usage() { cat >&2 <<'EOF' -usage: tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo 存取庫] [--no-header] +usage: tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo 存取庫] + [--spec-key jsc-{domain}:{技能名}] [--no-header] tasks.sh add --kind check|todo --title 一句話 --action 技能|腳本|remind --trigger at:...|after:... --recur once|every:...|cron:... - --origin user|assistant [--repo 存取庫] [--due ISO 時間] [--dry-run] + --origin user|assistant [--repo 存取庫] [--due ISO 時間] + [--spec-key jsc-{domain}:{技能名}] [--dry-run] tasks.sh done {id} [--last-run ISO 時間] [--next-run ISO 時間] tasks.sh fail {id} [--last-run ISO 時間] tasks.sh pause {id} tasks.sh resume {id} + tasks.sh remove {id} [--force] EOF exit 6 } @@ -284,6 +332,15 @@ valid_trigger() { # $1=trigger valid_recur() { case "$1" in once|every:?*|cron:?*) return 0 ;; esac; return 1; } +# spec_key 是重建內建項時的反查鍵,形狀固定 jsc-{domain}:{技能名},兩段都不得為空。 +# 收得寬一點的話,一個打錯的鍵會變成一筆永遠對不上清單的孤兒:重建那一邊查不到它, +# 所以既不會更新也不會移除,而它看起來跟一筆正常的內建項一模一樣。 +# 兩段都只收小寫英數與連字號:清單的前兩欄本來就長這樣,而冒號是分隔符號,值裡再出現一個 +# 就切不回兩段。 +valid_spec_key() { + printf '%s' "$1" | LC_ALL=C grep -qE '^jsc-[a-z0-9-]+:[a-z0-9-]+$' +} + valid_count() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; return 0; } # id 直接拿去接檔名,所以只收十六進位。帶斜線或點號開頭的值會把讀寫指到待辦簿目錄外面去。 @@ -350,7 +407,7 @@ now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } F_id=''; F_created=''; F_kind=''; F_title=''; F_action=''; F_trigger='' F_recur=''; F_repo=''; F_due=''; F_state=''; F_last_run=''; F_next_run='' -F_fail_count=''; F_origin='' +F_fail_count=''; F_origin=''; F_spec_key='' load_record() { # $1=檔案 F_id=$(kv_get "$1" id) @@ -367,6 +424,8 @@ load_record() { # $1=檔案 F_next_run=$(kv_get "$1" next_run) F_fail_count=$(kv_get "$1" fail_count) F_origin=$(kv_get "$1" origin) + # 舊檔案沒有這一鍵,讀回來就是空的,那正好是「不受清單管」的意思,不必補預設值也不必轉檔。 + F_spec_key=$(kv_get "$1" spec_key) # 手改過的檔案有可能把計數寫成別的東西。當成 0 再往上加,而不是讓算式整支炸掉:這一筆 # 的計數本來就已經不可信,讓它從 0 重新開始算得出來,比整支停下更有用。 if ! valid_count "$F_fail_count"; then @@ -396,6 +455,7 @@ write_record() { # $1=目標檔案 printf 'next_run=%s\n' "$F_next_run" printf 'fail_count=%s\n' "$F_fail_count" printf 'origin=%s\n' "$F_origin" + printf 'spec_key=%s\n' "$F_spec_key" } >"$_tmp" 2>/dev/null || { rm -f "$_tmp"; die 5 "待辦簿寫不進去:$_tmp。請確認 $TASKS_DIR 可寫。"; } mv "$_tmp" "$1" 2>/dev/null || { rm -f "$_tmp"; die 5 "待辦簿換不上去:$1。請確認 $TASKS_DIR 可寫。"; } } @@ -418,8 +478,9 @@ resolve_record() { # $1=id;設好 RECORD_FILE # 印出改完之後的那一筆,一行講完。改了什麼要看得到,不然呼叫端只拿到一個結束碼。 print_record_line() { - printf 'id=%s state=%s recur=%s last_run=%s next_run=%s fail_count=%s title=%s\n' \ - "$F_id" "$F_state" "$F_recur" "${F_last_run:--}" "${F_next_run:--}" "$F_fail_count" "$F_title" + printf 'id=%s state=%s recur=%s last_run=%s next_run=%s fail_count=%s spec_key=%s title=%s\n' \ + "$F_id" "$F_state" "$F_recur" "${F_last_run:--}" "${F_next_run:--}" "$F_fail_count" \ + "${F_spec_key:--}" "$F_title" } # --- list --- @@ -428,20 +489,24 @@ print_record_line() { # 不必再發明引號規則。空欄位就是空的一欄,不填占位符號:填了占位符號,讀的人得再去分 # 「真的空」與「占位符號本身」。 cmd_list() { - _f_kind=''; _f_state=''; _f_repo=''; _header=1 + _f_kind=''; _f_state=''; _f_repo=''; _f_spec=''; _header=1 while [ "$#" -gt 0 ]; do case "$1" in --kind) [ "$#" -ge 2 ] || usage; _f_kind="$2"; shift 2 ;; --state) [ "$#" -ge 2 ] || usage; _f_state="$2"; shift 2 ;; --repo) [ "$#" -ge 2 ] || usage; _f_repo="$2"; shift 2 ;; + --spec-key) [ "$#" -ge 2 ] || usage; _f_spec="$2"; shift 2 ;; --no-header) _header=0; shift ;; *) usage ;; esac done [ -z "$_f_kind" ] || valid_kind "$_f_kind" || die 2 "--kind 只收 check 或 todo,給的是「$_f_kind」。" [ -z "$_f_state" ] || valid_state "$_f_state" || die 2 "--state 只收 pending、done 或 paused,給的是「$_f_state」。" + # 這個篩選是重建內建項時的反查入口,所以形狀擋在這裡:一個打錯的鍵篩出零筆,跟「這一支 + # 還沒種進來」的結果一模一樣,而後者會讓呼叫端再補一筆。 + [ -z "$_f_spec" ] || valid_spec_key "$_f_spec" || die 2 "--spec-key「$_f_spec」不是 jsc-{domain}:{技能名} 這個形狀。打錯的鍵篩出零筆,跟「還沒種進來」看起來一樣,而那會讓呼叫端多加一筆。" - [ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\n' + [ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\tspec_key\n' # 目錄不存在或零筆都算正常結束:助理還沒收過任何一筆待辦,不是失敗。 if [ ! -d "$TASKS_DIR" ]; then @@ -459,16 +524,17 @@ cmd_list() { [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue + [ -z "$_f_spec" ] || [ "$_f_spec" = "$F_spec_key" ] || continue case "$F_state" in pending) _rank=0 ;; paused) _rank=1 ;; *) _rank=2 ;; esac _nrk="$F_next_run"; [ -n "$_nrk" ] || _nrk='~' - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$_rank" "$_nrk" "$F_id" \ "$F_id" "$F_kind" "$F_state" "$F_title" "$F_action" "$F_trigger" "$F_recur" \ - "$F_repo" "$F_due" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" + "$F_repo" "$F_due" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" "$F_spec_key" _n=$((_n + 1)) done | LC_ALL=C sort -t"$(printf '\t')" -k1,1 -k2,2 -k3,3 | cut -f4- @@ -480,6 +546,7 @@ cmd_list() { [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue + [ -z "$_f_spec" ] || [ "$_f_spec" = "$F_spec_key" ] || continue _n=$((_n + 1)) done printf 'count=%s tasks_dir=%s exists=yes\n' "$_n" "$TASKS_DIR" >&2 @@ -490,9 +557,10 @@ cmd_list() { cmd_add() { _kind=''; _title=''; _action=''; _trigger=''; _recur=''; _origin='' - _repo=''; _due=''; _dry=0 + _repo=''; _due=''; _spec=''; _dry=0 while [ "$#" -gt 0 ]; do case "$1" in + --spec-key) [ "$#" -ge 2 ] || usage; _spec="$2"; shift 2 ;; --kind) [ "$#" -ge 2 ] || usage; _kind="$2"; shift 2 ;; --title) [ "$#" -ge 2 ] || usage; _title="$2"; shift 2 ;; --action) [ "$#" -ge 2 ] || usage; _action="$2"; shift 2 ;; @@ -516,6 +584,7 @@ cmd_add() { _origin=$(fold_and_warn origin "$_origin") _repo=$(fold_and_warn repo "$_repo") _due=$(fold_and_warn due "$_due") + _spec=$(fold_and_warn spec_key "$_spec") [ -n "$_kind" ] || die 2 '缺 --kind。' valid_kind "$_kind" || die 2 "--kind 只收 check(定期檢查項)或 todo(交辦事項),給的是「$_kind」。" @@ -527,6 +596,10 @@ cmd_add() { valid_recur "$_recur" || die 2 "--recur「$_recur」不合法。只收 once、every:{間隔} 或 cron:{式子}。trigger 與 recur 是兩個獨立欄位,四種組合都成立,不要壓成兩種。" [ -n "$_origin" ] || die 2 '缺 --origin。user(使用者交辦)或 assistant(助理內建)。清單重建時只動 assistant 那幾筆,所以這一欄不能空。' valid_origin "$_origin" || die 2 "--origin 只收 user 或 assistant,給的是「$_origin」。" + if [ -n "$_spec" ]; then + valid_spec_key "$_spec" || die 2 "--spec-key「$_spec」不是 jsc-{domain}:{技能名} 這個形狀。形狀不對的鍵在重建時對不上清單,於是那一筆既不會更新也不會移除,而它看起來跟一筆正常的內建項一樣。" + [ "$_origin" = assistant ] || die 2 "--spec-key 只能配 --origin assistant,這一次的 origin 是「$_origin」。使用者交辦的那一筆帶上清單鍵,下一次重建就會拿清單去刪它,而規格明寫 origin=user 的項目一律不動。要照清單種入請用 assistant;要記下是為了哪一支技能交辦的,請寫進標題。" + fi _created=$(now_iso) # 雜湊吃的是「建立時間加標題」,中間夾一個定位字元當分隔。標題已經折過,裡面不會有定位 @@ -566,13 +639,15 @@ cmd_add() { # 一律生在 pending。生在 done 的那一筆是噪音,生在 paused 是事後才會有的人為決定。 F_state=pending F_last_run=''; F_next_run=''; F_fail_count=0; F_origin="$_origin" + F_spec_key="$_spec" if [ "$_dry" -eq 1 ]; then printf 'dryrun=add id=%s file=%s hash40=%s prefix_len=%s\n' "$_id" "$TASKS_DIR/$_id" "$_full" "$_len" printf -- '--- 會寫進去的內容 ---\n' - printf 'id=%s\ncreated=%s\nkind=%s\ntitle=%s\naction=%s\ntrigger=%s\nrecur=%s\nrepo=%s\ndue=%s\nstate=%s\nlast_run=%s\nnext_run=%s\nfail_count=%s\norigin=%s\n' \ + printf 'id=%s\ncreated=%s\nkind=%s\ntitle=%s\naction=%s\ntrigger=%s\nrecur=%s\nrepo=%s\ndue=%s\nstate=%s\nlast_run=%s\nnext_run=%s\nfail_count=%s\norigin=%s\nspec_key=%s\n' \ "$F_id" "$F_created" "$F_kind" "$F_title" "$F_action" "$F_trigger" "$F_recur" \ - "$F_repo" "$F_due" "$F_state" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" + "$F_repo" "$F_due" "$F_state" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" \ + "$F_spec_key" return 0 fi @@ -717,6 +792,33 @@ cmd_resume() { return 0 } +# --- remove --- + +cmd_remove() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + _force=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --force) _force=1; shift ;; + *) usage ;; + esac + done + open_target "$_id" + # 擋在這裡而不擋在呼叫端,理由見檔頭「remove 為什麼要擋使用者交辦的那幾筆」。 + if [ "$F_origin" = user ] && [ "$_force" -eq 0 ]; then + die 7 "id=$F_id 的 origin 是 user,這是使用者交辦的事,remove 不動它。清單重建那一輪一律不帶 --force:助理不會因為一支技能改判就把使用者交辦的事刪掉。要真的刪請人親自帶 --force 再跑一次。" + fi + # 先把內容留在畫面上再刪。刪掉之後那一筆的欄位就再也拿不回來了,回報裡只剩一個 id 的話, + # 誰都看不出剛剛不見的是什麼。 + print_record_line + rm -f "$RECORD_FILE" 2>/dev/null || die 5 "刪不掉 $RECORD_FILE。請確認 $TASKS_DIR 可寫。" + # rm 回 0 不保證檔案真的不在了:唯讀目錄底下的 rm 有可能什麼都沒做。所以回報之前再看一次。 + [ -f "$RECORD_FILE" ] && die 5 "$RECORD_FILE 還在,這一筆沒有刪掉。請確認 $TASKS_DIR 可寫。" + printf 'removed=%s file=%s origin=%s spec_key=%s\n' \ + "$F_id" "$RECORD_FILE" "$F_origin" "${F_spec_key:--}" + return 0 +} + # --- 主流程 --- RECORD_FILE='' @@ -730,6 +832,7 @@ case "$CMD" in fail) cmd_fail "$@" ;; pause) cmd_pause "$@" ;; resume) cmd_resume "$@" ;; + remove) cmd_remove "$@" ;; *) usage ;; esac exit $?