From 2ec43799247fa6582044992db77a045c53b5e88f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 15:46:02 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(tasks):=20=E5=8A=A9=E7=90=86=E5=BE=85?= =?UTF-8?q?=E8=BE=A6=E7=B0=BF=E7=9A=84=E5=AD=98=E6=94=BE=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E8=88=87=E8=AE=80=E5=AF=AB=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 助理要記住兩種項目——內建的定期檢查項與使用者當面交辦的事——但目前沒有地方放。待辦簿是助理待辦與建議下一個指令兩批功能的共同地基,先把存放格式與讀寫工具做出來,判定邏輯之後才接。 存成一筆一檔,純文字 key=value。一筆一檔的理由同重啟閘門那組檔案:並行寫入不互相覆寫。五支 CLI 加上排程那一輪有可能同時動待辦簿,整本存成一個檔案的話兩邊各讀一次整檔、各改自己那一筆、各寫回整檔,後寫的那一次就把前一次整本蓋掉,而且沒有任何訊號。同一個 id 被同時寫也不會寫出半份:先寫暫存檔再搬過去,搬在同一個檔案系統上是原子操作。暫存檔名以點號開頭,列表跳過點號開頭的檔案,寫到一半的那一份不會被列出來。 規格的欄位表少一個欄位。id 是建立時間加標題的雜湊,但表上沒有存建立時間的欄位,不存它就再也算不回同一個 id、驗不出檔名對不對、前綴碰撞時也接不下去。補上建立時間,所以一筆是十四個鍵。 id 取完整雜湊的前八碼,不跟 wiki 頁名的四十碼一致。兩者用途不同:四十碼那條規則管的是 wiki 頁名,頁名一撞就是兩台機器的紀錄互相覆寫且看不出來,所以不准截短;這裡的 id 是本機檔名,人要念它、打進指令、看它印在狀態表與提醒文字裡,四十碼沒有人讀得完也打不對,人就會改用「第三筆」這種說法指定要關哪一筆,那才是真正會關錯的地方。前八碼是同一個雜湊的前綴不是另一套算法,拿建立時間與標題重算就驗得回來。前綴撞上而內容不同就每次多取兩碼再試,不在後面補序號——補了就算不回來。建立時間與標題都相同的不是碰撞而是同一筆被登錄兩次,一律不寫並回專屬結束碼,因為定期檢查項會因為清單重建而重跑登錄,靜靜多寫一筆會讓同一個檢查每輪做兩次。 操作補到六個,兩個都是補規格的洞。失敗那一個是因為規格說失敗要累加連續失敗次數,但原本四個操作沒有一個寫得到它,少了它那個計數永遠是零,監控頁與提醒上那句「已連續失敗幾次」永遠是零次,於是一個壞掉的項目每輪重試而沒人知道,那正是這個欄位要防的事。恢復那一個是因為停用只由人設,也就只有人解得開,沒有別的元件寫得出這個轉移;只給停用不給恢復就是一道單向門,人只能去手改檔案,而手改繞過了轉移表。 上次執行與下次執行不另開操作:完成與失敗都吃選項把值餵進來,值由算到期那一邊算好。這一支不算下一次是什麼時候,兩邊各算一次就會漂移。也刻意沒有編輯操作:id 由建立時間與標題算出來,改掉標題之後 id 就對不回去了。 狀態轉移寫成明確的表並在腳本裡擋住不合法的轉移。停用之後不收完成,因為停掉的那一筆助理本來就沒在跑,標成做完等於偷偷解開又收掉;停用之後不收失敗,因為助理沒在跑它就不可能是它失敗;收掉的那一筆不再有下一次,四種操作全擋。恢復刻意不把連續失敗次數歸零,那幾次失敗真的發生過,歸零會把提醒抹掉而那筆一恢復就會照樣再失敗,要歸零就等它跑成功一次。 值裡有等號或換行用兩條約定處理,不發明跳脫規則。讀的時候只在第一個等號斷開,十四個鍵都是固定的詞、一個都不含等號。寫的時候把值折成一行並在標準錯誤記一行說折過了。選折行不選跳脫,是因為讀的人不只這一支腳本,技能本文與巡檢那一輪都直接把檔案當 key=value 讀,跳脫規則要每個讀的人各自實作一次,漏掉一個就會把跳脫序列原樣印進報告或監控頁,而那看起來很像正常內容。 根目錄解析退回使用者家目錄底下那一層,跟排程、巡檢與心跳三支退回同一個值——兩邊退回的位置不同,待辦簿就會躲在一個沒有人去讀的目錄裡,而每一支都自認為讀對了。連家目錄也沒有才拒絕,不猜路徑:猜錯就是把待辦寫到一個沒有人會去讀的地方,而且看起來像成功。 實測抓到一個真的臭蟲並修掉。查找記錄原本寫成印出路徑、由呼叫端用命令替換接,於是裡面的錯誤退出只結束子行程、外層照樣往下走,訊息說欄位不合法卻回了找不到那一筆的碼,呼叫端拿到的碼與真正的原因不一樣。改寫成直接設變數再回碼。 這一輪只做存放與讀寫。登錄時的補問流程、事件詞彙表接上產生者、到期判定、逾期與失敗處理、提醒送到前景、wiki 雙向同步,六項都不在這一輪,但欄位都已經留在檔案裡,接的時候不必改存放格式。目前還沒有任何操作呼叫得到這六個子命令,行為契約只記下這一支工具存在,不讓清單先跑到技能前面去。 --- references/behaviors.md | 2 +- tools/tasks.sh | 735 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 736 insertions(+), 1 deletion(-) create mode 100755 tools/tasks.sh diff --git a/references/behaviors.md b/references/behaviors.md index d8be1de..1c52963 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -8,6 +8,6 @@ | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `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` 取鎖並讀五項來源(第五項是執行狀態事件:`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-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/` 底下的檔案。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | +| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{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 碼、碰撞時每次加長兩碼,六個子命令與結束碼的完整說明寫在那一支的檔頭。本技能現在只自己讀那些檔案,`tasks.sh` 的 `list`、`add`、`done`、`fail`、`pause`、`resume` 六個子命令還沒有任何一個操作呼叫得到:這一輪只把存放格式與工具定下來,到期判定、逾期與失敗處理、提醒怎麼送到前景、欄位不足時怎麼補問,一項都還沒接上去。呼叫端沒講清楚要哪一個操作時走 `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`、執行狀態事件那一項要印出本輪事件數、非 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/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`,不落在任何存取庫 | diff --git a/tools/tasks.sh b/tools/tasks.sh new file mode 100755 index 0000000..8f15e23 --- /dev/null +++ b/tools/tasks.sh @@ -0,0 +1,735 @@ +#!/usr/bin/env sh +# tasks.sh — 助理待辦簿的存放與讀寫(供 jsc-assist:assistant 與巡檢那一輪呼叫)。 +# +# 用法: +# tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo {存取庫}] +# [--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] +# 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} +# +# 結束碼: +# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了。 +# pause 對已經是 paused 的那一筆、resume 對已經是 pending 的那一筆,照樣回 0:同一個 +# 狀態不算轉移,擋它只會讓呼叫端為了「本來就對」的結果去分流 +# 1 指名的那一筆不存在:done、fail、pause、resume 給的 id 找不到對應檔案 +# 2 欄位值不合法:必填欄位缺、值不在允許集合、事件名不在固定詞彙表、標題折完是空的、 +# id 不是十六進位、fail_count 不是非負整數 +# 3 不合法的狀態轉移,已擋下。哪些合法見下面「狀態怎麼轉」那張表 +# 4 這一筆已經有了:add 算出來的完整雜湊撞上一個「建立時間與標題都相同」的既有檔案 +# 5 檔案系統或雜湊失敗:待辦簿目錄建不起來、檔案寫不進去、這台機器算不出 SHA-1 +# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、缺 id,或 JSC_HOME 與 HOME 都 +# 解不出絕對路徑(沒有根目錄可寫,猜一個等於把待辦簿寫到別的地方去) +# +# --- 這一支負責什麼、不負責什麼 --- +# +# 只負責存放與讀寫:把一筆待辦寫成檔案、讀回來、改狀態。到期判定、逾期判定、提醒怎麼送到 +# 前景、事件名怎麼對上產生者、欄位不足時怎麼問人,全部不在這一支裡面。 +# 所以這一支**留得住**那些欄位,但不對它們做判定: +# 只存放,這一支不判定的欄位 +# trigger 只驗格式與事件詞彙表,不算「現在到期了沒有」 +# recur 只驗格式,不算下一次是什麼時候 +# due 只存字串,不比對現在時間,不標逾期 +# next_run 只存呼叫端算好的值;這一支自己一次都不算 +# last_run done 與 fail 會寫進去,寫的是「這一次執行的時間」,不拿它推算任何事 +# fail_count fail 累加、done 歸零,這一支不因為它到某個數字就改 state +# 這一支自己判定的只有兩件事:欄位值合不合法(結束碼 2),與狀態轉移合不合法(結束碼 3)。 +# 判定邏輯後續才接上來,接的時候不必改這裡的存放格式——欄位已經在檔案裡了。 +# +# --- 一筆一檔的理由 --- +# +# 待辦簿存成 $JSC_HOME/assistant/tasks/{id},一筆一檔,理由同 restart-required.d:並行寫入 +# 不互相覆寫。五支 CLI 加上排程那一輪有可能同時動待辦簿,整本存成一個檔案的話,兩邊各讀 +# 一次整檔、各改自己那一筆、各寫回整檔,後寫的那一次就把前一次的改動整本蓋掉,而且沒有 +# 任何訊號。一筆一檔之下,動的是不同的 id 就是動不同的檔案,彼此看不到對方。 +# 同一個 id 被同時寫時也不會寫出半份:一律先寫進暫存檔再 mv 過去,mv 在同一個檔案系統上是 +# 原子操作,讀的人只會讀到舊的一整份或新的一整份,不會讀到寫到一半的內容。 +# 暫存檔名一律以點號開頭,list 的展開跳過點號開頭的檔案:寫到一半的那一份不會被列出來。 +# +# --- 存放格式:純文字 key=value,一行一欄位 --- +# +# 一筆固定十四個鍵,順序固定,缺一個都不寫。十四個裡有兩個是格式自己需要的: +# id 檔名,也寫進檔案裡一份。只看檔名的話,檔案被複製或改名之後就對不上內容 +# created 建立時間,UTC 的 ISO 時間。id 是由它與 title 算出來的,不存它就再也算不回 +# 同一個 id,也就驗不出檔名對不對,碰撞時也接不下去 +# 其餘十二個是待辦本身的欄位: +# kind check(定期檢查項)或 todo(交辦事項)。同一本簿、同一組欄位,只用它分 +# title 一句話講完要做什麼 +# action 助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手) +# trigger 第一次什麼時候到期。at:{ISO 時間}、at:now,或 after:{事件名} +# recur 做完之後還要不要再排。once、every:{間隔},或 cron:{式子} +# repo 這一筆綁哪一個存取庫。機器層級的檢查項留空 +# due 截止時間。留空就是沒有截止時間,那是合法狀態,不是缺欄位 +# state pending、done 或 paused +# last_run 上一次執行的時間 +# next_run 下一次預定執行的時間 +# fail_count 連續失敗次數 +# origin user(使用者交辦)或 assistant(助理內建) +# +# 值是空的照樣把那一行寫出來(例如 repo=)。空值有明確的意思——沒有綁存取庫、沒有截止 +# 時間、還沒跑過——所以讓每一筆的形狀都一樣,讀的人不必去分「鍵不見了」與「鍵在但是空的」, +# 兩眼一比就看得出哪一欄沒填。不認得的鍵一律忽略,往後加欄位不會讓舊檔案讀不進來。 +# +# --- 值裡有等號或換行怎麼辦 --- +# +# 兩條約定,合起來讓這個格式壞不了,而且不必發明跳脫規則: +# 一、讀的時候只在**第一個等號**斷開。鍵是固定的十四個詞,一個都不含等號,所以第一個 +# 等號一定是分隔符號,後面全部算值。title=a=b 讀回來就是 a=b,寫的時候不必動它。 +# 二、寫的時候把值**折成一行**:換行、歸位、定位字元各折成一個空白,其餘控制字元刪掉, +# 連續空白併成一個,前後空白去掉。折過就在 stderr 記一行,不靜靜改人家的值。 +# 第二條選折行而不選跳脫,理由是讀的人不只這一支腳本:技能本文與巡檢那一輪都會直接把檔案 +# 當 key=value 讀。跳脫規則要每一個讀的人各自實作一次,漏掉一個,那個人就把 \n 兩個字原樣 +# 印進報告或監控頁,看起來還很像正常內容。不跳脫就不用還原,每一個讀的人只要在第一個等號 +# 斷開,拿到的就是存進去的那個值。 +# 代價是值裡真的換行會被折掉。這本簿的每一個欄位本來就都是一行——標題是一句話、時間是一個 +# 時間戳、動作是一個技能名或腳本——折行沒有丟掉屬於這本簿的資訊。真的需要長篇內容的東西 +# 該寫成 wiki 頁再用 action 指過去,不是塞進標題。 +# 另外,命令替換本來就會吃掉結尾的換行,所以值傳到這裡之前結尾的換行已經不見了。這件事 +# 講在這裡,是為了讓人不要以為折行有保住結尾的換行。 +# +# --- id 為什麼取前 8 碼,碰撞怎麼辦 --- +# +# 共用 hash 規則(jsc-gitea 的 tools/hash-id)是完整四十碼大寫、不截短。這一支照樣先算出 +# 完整四十碼,只在取檔名的時候取前 8 碼,理由是兩者的用途不同: +# 四十碼那個規則管的是 wiki 頁名。頁名要在整個站台裡唯一,而且頁名一撞就是兩台機器的 +# 紀錄互相覆寫,看不出來,所以那裡不准截短。 +# 這裡的 id 是本機檔名,還要被人念出來、打進 done 與 pause、印在狀態表與提醒文字裡。 +# 四十碼的十六進位字串塞進表格沒有人讀得完,也沒有人打得對,於是人會改用「第三筆」這種 +# 說法指定要關哪一筆,那才是真正會關錯的地方。 +# 兩者不必一致,因為 id 在 wiki 上只是某一列裡的一個值,不是頁名,撞不到頁名的唯一性。 +# 前 8 碼是完整四十碼的前綴,不是另一套算法:要驗一個 id 對不對,就拿 created 與 title +# 重算四十碼,再比前綴,隨時驗得回來。 +# 碰撞這樣處理: +# 前 8 碼撞上既有檔案,而那個檔案的 created 或 title 跟這一筆不同,就是真的前綴碰撞。 +# 把前綴每次多取兩碼(8、10、12……一路到 40)再試,取到不撞為止。多取的還是同一個 +# 雜湊的前綴,所以前一段那個「重算就驗得回來」的性質不變;不在後面補 -2 這種序號, +# 補序號的 id 就再也算不回來了。 +# created 與 title 都相同的話,那不是碰撞,那是同一筆被登錄兩次——同一秒、同一個標題就是 +# 同一件事。這時候一律不寫,回 4 並把既有的 id 印出來。定期檢查項會因為清單重建而重跑 +# 登錄,靜靜多寫一筆的話,同一個檢查每輪就會做兩次。 +# 同一秒登錄兩筆不同標題的待辦不會撞:雜湊吃的是「建立時間加標題」,標題不同雜湊就不同。 +# +# --- 狀態怎麼轉 --- +# +# 只有三個狀態,合法的轉移就這幾條,其餘一律回 3 擋下: +# 起點 操作 終點 說明 +# (不存在) add pending 一律生在 pending。生在 done 的那一筆是 +# 噪音;生在 paused 是事後才會有的人為決定 +# pending done done(recur 是 once) 一次性做完就收掉 +# pending done pending(recur 會重複) 重複的做完要重新排,所以留在 pending +# pending fail pending 失敗只累加 fail_count,state 不動 +# pending pause paused 只有人會下這個操作 +# paused resume pending paused 只由人設,也只有人解得開 +# pending resume pending(不算轉移,回 0) +# paused pause paused(不算轉移,回 0) +# 被擋下的幾條,各自的理由: +# paused + done 停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收 +# 先 resume,讓「解開」這件事是人做的、看得到的 +# paused + fail 同理。助理沒有跑它,就不可能是它失敗 +# done + 任何 一次性且已經收掉的那一筆不再有下一次。再 done 一次會改寫 last_run, +# 再 pause 一次會讓它看起來在等人解開 +# 助理自己絕不寫 paused:能寫出 paused 的只有 pause 這一個操作,而巡檢那一輪只會叫 done +# 與 fail。失敗連續幾次都一樣留在 pending,靠 fail_count 讓人看到,不自動停掉——自動停掉 +# 等於助理自己決定不做某件事,而且沒有人會發現。 +# +# --- 為什麼是六個操作,不是四個 --- +# +# 存放層要的是四個:list、add、done、pause。另外兩個是補洞,不是加功能: +# fail last_run、next_run、fail_count 三個欄位由助理自己維護、不由人填,但四個操作裡 +# 沒有一個寫得到 fail_count。少了它,fail_count 永遠是 0,監控頁與提醒上的 +# 「已連續失敗 N 次」就永遠是 0 次,於是一個壞掉的項目每輪重試而沒有人知道—— +# 那正是這個欄位要防的事。所以失敗這條路要有自己的入口。 +# resume paused 只由人設,也就只有人解得開,沒有別的元件寫得出這個轉移。只給 pause +# 不給 resume,pause 就是一道單向門:停掉的那一筆再也回不來,人只能去手改檔案, +# 而手改檔案繞過了上面那張轉移表。 +# last_run 與 next_run 不另開操作:done 與 fail 都吃 --last-run 與 --next-run,值由呼叫端 +# 算好餵進來。這一支不算下一次是什麼時候,算的邏輯在別的地方,兩邊各算一次就會漂移。 +# 沒有 edit 操作。改欄位值要重新登錄一筆,理由是 id 由 created 與 title 算出來,改掉標題 +# 之後 id 就對不回去了,留一個算不回來的 id 比多一筆待辦糟。 +# +# 環境變數: +# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 +# JSC_HASH_ID 共用 hash 規則那一支的路徑,優先於自動搜尋 +set -u + +JSC_HOME_RAW="${JSC_HOME:-}" +if [ -z "$JSC_HOME_RAW" ]; then + # JSC_HOME 沒設就退回 ~/.jsc,與這個 domain 的其他腳本同一個預設值:兩邊退回的位置不同, + # 待辦簿就會躲在一個沒有人去讀的目錄裡,而每一支都自認為讀對了。 + 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" + JSC_HOME_FALLBACK=1 +else + JSC_HOME_FALLBACK=0 +fi + +# 根目錄一定要是絕對路徑。相對路徑在排程那一輪等於指向 cron 的工作目錄,那一輪會把待辦簿 +# 寫到別的地方去,而下一輪從正確的地方讀,看到的是零筆。 +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" +STATE_DIR="$JSC_HOME/assistant" +TASKS_DIR="$STATE_DIR/tasks" +CURRENT="$JSC_HOME/current" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) +SCRIPT_DIR="${SCRIPT_DIR:-.}" + +die() { # $1=結束碼 $2=訊息 + printf '[jsc][助理待辦簿][ERR]:%s\n' "$2" >&2 + exit "$1" +} + +note() { printf '[jsc][助理待辦簿]:%s\n' "$1" >&2; } +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] + tasks.sh add --kind check|todo --title 一句話 --action 技能|腳本|remind + --trigger at:...|after:... --recur once|every:...|cron:... + --origin user|assistant [--repo 存取庫] [--due ISO 時間] [--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} +EOF + exit 6 +} + +# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。判準與處置同這個 domain 的其他 +# 腳本:只警告、照跑。從工作樹直接跑是開發時的正當用法,中止會把那條路擋掉;真正的失敗 +# 會發生在權限閘門那裡,閘門只放行 current 那一組確切路徑。 +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 + +[ "$JSC_HOME_FALLBACK" -eq 1 ] && note "JSC_HOME 沒有設定,這一次用 $JSC_HOME。待辦簿的位置會隨 HOME 變動,排程那一輪與現在這個殼的 HOME 不一定相同:要固定就把 JSC_HOME 設起來。" + +# --- 值的讀與寫 --- + +# 取一個鍵的值。只在第一個等號斷開,所以值裡的等號原樣讀回來。 +# 先把歸位字元刪掉:這一支寫出來的檔案沒有歸位字元,但手改過的檔案可能有,留著會混進值裡。 +# 同一個鍵重複出現時只認第一次,不把兩行併起來——併起來會生出一個誰都沒寫過的值。 +kv_get() { # $1=檔案 $2=鍵 + tr -d '\r' <"$1" 2>/dev/null | sed -n "s/^$2=//p" | head -n1 +} + +# 把值折成一行。換行、歸位、定位字元折成空白,其餘控制字元刪掉,連續空白併一個,前後去掉。 +fold_value() { # $1=原值 + printf '%s' "$1" \ + | tr '\n\r\t' ' ' \ + | tr -d '\000-\037' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[[:space:]][[:space:]]*/ /g' +} + +# 折過就講一聲。靜靜改掉人家給的值,下一次他從報告裡看到的東西跟他給的不一樣,而且找不到 +# 是誰改的。 +fold_and_warn() { # $1=欄位名 $2=原值;印出折好的值 + _f=$(fold_value "$2") + if [ "$_f" != "$2" ]; then + warn "$1 的值裡有換行、定位字元或多餘空白,已經折成一行:「$_f」。這本簿的每一個欄位都是一行,長篇內容請另外寫成 wiki 頁再用 action 指過去。" + fi + printf '%s' "$_f" +} + +# --- 欄位值的合法性 --- + +valid_kind() { case "$1" in check|todo) return 0 ;; esac; return 1; } +valid_state() { case "$1" in pending|done|paused) return 0 ;; esac; return 1; } +valid_origin() { case "$1" in user|assistant) return 0 ;; esac; return 1; } + +# 事件名只認固定詞彙表。理由:填一個永遠不會發生的事件名,那筆待辦就永遠不到期,而且從 +# 檔案上看不出壞在哪——它看起來跟一筆正常的待辦一模一樣。所以寫進去的那一刻就擋。 +# 四個不帶參數,三個一定要帶參數;帶不帶寫錯一律當不合法,不自己補。 +valid_event() { # $1=after: 後面那一整段 + case "$1" in + worklog-written|hook-error|session-start|session-end) return 0 ;; + wp-merged:?*|stage-entered:?*|analyze-completed:?*) return 0 ;; + esac + return 1 +} + +valid_trigger() { # $1=trigger + case "$1" in + at:?*) return 0 ;; + after:?*) valid_event "${1#after:}" && return 0; return 1 ;; + esac + return 1 +} + +valid_recur() { case "$1" in once|every:?*|cron:?*) return 0 ;; esac; return 1; } + +valid_count() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; return 0; } + +# id 直接拿去接檔名,所以只收十六進位。帶斜線或點號開頭的值會把讀寫指到待辦簿目錄外面去。 +# 長度收 8 到 40:8 是預設前綴,碰撞時會加長,加長後最多就是完整四十碼。 +# 用 grep 而不用 case 的否定字集,是因為那種寫法在註解掃描裡會被認成別的東西。 +valid_id() { + _n=${#1} + [ "$_n" -ge 8 ] && [ "$_n" -le 40 ] || return 1 + printf '%s' "$1" | LC_ALL=C grep -qE '^[0-9A-Fa-f]{8,40}$' +} + +# --- 雜湊 --- + +# 找共用 hash 規則那一支。搜尋順序比照這個 domain 其他腳本找 jsc-hooks 的做法:先環境變數 +# 覆寫,再 current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的快取版面。 +# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部自己去挑另一個 +# 版本,同一輪就會跑到混版的工具,那種不一致查起來沒有線索。 +hash_id_sh() { + if [ -n "${JSC_HASH_ID:-}" ] && [ -f "$JSC_HASH_ID" ]; then + printf '%s\n' "$JSC_HASH_ID"; return 0 + fi + if [ -f "$CURRENT/jsc-gitea/tools/hash-id" ]; then + printf '%s\n' "$CURRENT/jsc-gitea/tools/hash-id"; return 0 + fi + _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" + for _c in "$_root/../gitea/tools/hash-id" "$_root/../jsc-gitea/tools/hash-id"; do + [ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/hash-id\n' "$(pwd)"); return 0; } + done + _c=$(ls "$_root"/../../jsc-gitea/*/tools/hash-id \ + "$_root"/../../gitea/*/tools/hash-id \ + "$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/hash-id 2>/dev/null \ + | sort | tail -n1) + [ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + return 1 +} + +# 算出完整四十碼大寫。優先叫共用那一支;那一支找不到才自己算。 +# 備援不能拿掉:jsc-gitea 不一定裝在這台機器上,缺了它就一筆待辦都登錄不了,而登錄不了的 +# 那一刻使用者就在現場,錯過了就再也問不到。備援算的是同一條規則——完整四十碼、a-f 轉大寫、 +# 不截短——所以兩條路算出來的值相同,只有「取前綴當檔名」這一步是本機的事。 +hash40() { # $1=要算的字串 + _h=$(hash_id_sh 2>/dev/null) || _h='' + if [ -n "$_h" ]; then + _out=$(printf '%s' "$1" | sh "$_h" 2>/dev/null) || _out='' + case "$_out" in + [0-9A-F]*) printf '%s' "$_out"; return 0 ;; + esac + warn "共用 hash 規則那一支($_h)算不出雜湊,這一次改用本機的 SHA-1。兩者是同一條規則,值相同。" + else + warn '找不到共用 hash 規則那一支(jsc-gitea 的 tools/hash-id),這一次改用本機的 SHA-1。兩者是同一條規則,值相同。' + fi + if command -v sha1sum >/dev/null 2>&1; then + printf '%s' "$1" | sha1sum | awk '{print $1}' | tr a-f A-F; return 0 + fi + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 1 | awk '{print $1}' | tr a-f A-F; return 0 + fi + return 1 +} + +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='' + +load_record() { # $1=檔案 + F_id=$(kv_get "$1" id) + F_created=$(kv_get "$1" created) + F_kind=$(kv_get "$1" kind) + F_title=$(kv_get "$1" title) + F_action=$(kv_get "$1" action) + F_trigger=$(kv_get "$1" trigger) + F_recur=$(kv_get "$1" recur) + F_repo=$(kv_get "$1" repo) + F_due=$(kv_get "$1" due) + F_state=$(kv_get "$1" state) + F_last_run=$(kv_get "$1" last_run) + F_next_run=$(kv_get "$1" next_run) + F_fail_count=$(kv_get "$1" fail_count) + F_origin=$(kv_get "$1" origin) + # 手改過的檔案有可能把計數寫成別的東西。當成 0 再往上加,而不是讓算式整支炸掉:這一筆 + # 的計數本來就已經不可信,讓它從 0 重新開始算得出來,比整支停下更有用。 + if ! valid_count "$F_fail_count"; then + [ -n "$F_fail_count" ] && warn "$1 的 fail_count 是「$F_fail_count」,不是非負整數,這一次當成 0。" + F_fail_count=0 + fi + [ -n "$F_state" ] || F_state=pending +} + +# 整份寫進暫存檔再 mv 過去。mv 在同一個檔案系統上是原子操作,所以讀的人只會讀到舊的一整份 +# 或新的一整份。暫存檔名帶行程號,兩個同時在跑的行程不會互搶同一個暫存檔;名字以點號開頭, +# list 的展開跳過它,寫到一半的那一份不會被列出來。 +write_record() { # $1=目標檔案 + _tmp="$TASKS_DIR/.tmp.$$" + { + printf 'id=%s\n' "$F_id" + printf 'created=%s\n' "$F_created" + printf 'kind=%s\n' "$F_kind" + printf 'title=%s\n' "$F_title" + printf 'action=%s\n' "$F_action" + printf 'trigger=%s\n' "$F_trigger" + printf 'recur=%s\n' "$F_recur" + printf 'repo=%s\n' "$F_repo" + printf 'due=%s\n' "$F_due" + printf 'state=%s\n' "$F_state" + printf 'last_run=%s\n' "$F_last_run" + printf 'next_run=%s\n' "$F_next_run" + printf 'fail_count=%s\n' "$F_fail_count" + printf 'origin=%s\n' "$F_origin" + } >"$_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 可寫。"; } +} + +ensure_dir() { + [ -d "$TASKS_DIR" ] && return 0 + mkdir -p "$TASKS_DIR" 2>/dev/null || die 5 "建不出待辦簿目錄:$TASKS_DIR。" +} + +# 指名那一筆的檔案路徑。 +# 這一段刻意不寫成「印出路徑、由呼叫端用命令替換接」的函式:那樣它是在子行程裡跑,裡面的 +# die 只結束子行程,外面照樣往下走,於是「id 不合法」會被回報成「找不到那一筆」,結束碼 +# 也從 2 變成 1。呼叫端拿到的碼與真正的原因不一樣,比沒有分碼更糟。 +resolve_record() { # $1=id;設好 RECORD_FILE + valid_id "$1" || die 2 "id「$1」不是 8 到 40 碼的十六進位。id 直接拿去接檔名,帶別的字元會把讀寫指到待辦簿目錄外面去。" + _up=$(printf '%s' "$1" | tr a-f A-F) + RECORD_FILE="$TASKS_DIR/$_up" + [ -f "$RECORD_FILE" ] || die 1 "待辦簿裡找不到 id=$_up。請先跑 list 看現有的幾筆;id 是十六進位,大小寫都收。" +} + +# 印出改完之後的那一筆,一行講完。改了什麼要看得到,不然呼叫端只拿到一個結束碼。 +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" +} + +# --- list --- + +# 輸出是定位字元分隔。值一律折過,裡面不會有定位字元也不會有換行,所以定位字元分隔讀得準, +# 不必再發明引號規則。空欄位就是空的一欄,不填占位符號:填了占位符號,讀的人得再去分 +# 「真的空」與「占位符號本身」。 +cmd_list() { + _f_kind=''; _f_state=''; _f_repo=''; _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 ;; + --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」。" + + [ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\n' + + # 目錄不存在或零筆都算正常結束:助理還沒收過任何一筆待辦,不是失敗。 + if [ ! -d "$TASKS_DIR" ]; then + printf 'count=0 tasks_dir=%s exists=no\n' "$TASKS_DIR" >&2 + return 0 + fi + + _n=0 + # 排序鍵:state 分組(pending、paused、done),再 next_run,再 id。pending 排在前面是 + # 因為那是要看的東西;沒有 next_run 的排在同組最後,鍵補 ~ —— LC_ALL=C 之下它排在 + # 數字與字母後面,所以「還沒排下一次」的那幾筆不會擠在有時間的前面。 + for _fp in "$TASKS_DIR"/*; do + [ -f "$_fp" ] || continue + load_record "$_fp" + [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue + [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue + [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || 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' \ + "$_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" + _n=$((_n + 1)) + done | LC_ALL=C sort -t"$(printf '\t')" -k1,1 -k2,2 -k3,3 | cut -f4- + + # 上面那一段在管線的子行程裡跑,_n 加不回來,所以計數另外數一次。 + _n=0 + for _fp in "$TASKS_DIR"/*; do + [ -f "$_fp" ] || continue + load_record "$_fp" + [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue + [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue + [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue + _n=$((_n + 1)) + done + printf 'count=%s tasks_dir=%s exists=yes\n' "$_n" "$TASKS_DIR" >&2 + return 0 +} + +# --- add --- + +cmd_add() { + _kind=''; _title=''; _action=''; _trigger=''; _recur=''; _origin='' + _repo=''; _due=''; _dry=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --kind) [ "$#" -ge 2 ] || usage; _kind="$2"; shift 2 ;; + --title) [ "$#" -ge 2 ] || usage; _title="$2"; shift 2 ;; + --action) [ "$#" -ge 2 ] || usage; _action="$2"; shift 2 ;; + --trigger) [ "$#" -ge 2 ] || usage; _trigger="$2"; shift 2 ;; + --recur) [ "$#" -ge 2 ] || usage; _recur="$2"; shift 2 ;; + --origin) [ "$#" -ge 2 ] || usage; _origin="$2"; shift 2 ;; + --repo) [ "$#" -ge 2 ] || usage; _repo="$2"; shift 2 ;; + --due) [ "$#" -ge 2 ] || usage; _due="$2"; shift 2 ;; + --dry-run) _dry=1; shift ;; + *) usage ;; + esac + done + + # 必填欄位一個都不補預設值。猜出來的時間點與週期會讓助理拿一個沒有人同意過的時程去跑, + # 半筆待辦比沒有待辦更糟。缺了就回 2,讓呼叫端當著使用者的面把它問回來。 + _kind=$(fold_and_warn kind "$_kind") + _title=$(fold_and_warn title "$_title") + _action=$(fold_and_warn action "$_action") + _trigger=$(fold_and_warn trigger "$_trigger") + _recur=$(fold_and_warn recur "$_recur") + _origin=$(fold_and_warn origin "$_origin") + _repo=$(fold_and_warn repo "$_repo") + _due=$(fold_and_warn due "$_due") + + [ -n "$_kind" ] || die 2 '缺 --kind。' + valid_kind "$_kind" || die 2 "--kind 只收 check(定期檢查項)或 todo(交辦事項),給的是「$_kind」。" + [ -n "$_title" ] || die 2 '缺 --title,或標題折完之後是空的。標題是一句話講完要做什麼,空標題在狀態表上認不出是哪一筆。' + [ -n "$_action" ] || die 2 '缺 --action。助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手)。' + [ -n "$_trigger" ] || die 2 '缺 --trigger。第一次什麼時候到期:at:{ISO 時間}、at:now,或 after:{事件名}。' + valid_trigger "$_trigger" || die 2 "--trigger「$_trigger」不合法。只收 at:{ISO 時間}、at:now,或 after:{事件名};事件名只認這七個:worklog-written、hook-error、session-start、session-end、wp-merged:{工作包代號}、stage-entered:{階段}、analyze-completed:{HASH}。填一個不在表上的事件名,那筆待辦永遠不到期,而且從檔案上看不出壞在哪。" + [ -n "$_recur" ] || die 2 '缺 --recur。做完之後還要不要再排:once、every:{間隔},或 cron:{式子}。' + 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」。" + + _created=$(now_iso) + # 雜湊吃的是「建立時間加標題」,中間夾一個定位字元當分隔。標題已經折過,裡面不會有定位 + # 字元,所以這個分隔切得乾淨:不夾分隔的話,時間結尾與標題開頭黏起來會有兩組不同的輸入 + # 算出同一個雜湊。 + _full=$(hash40 "$_created$(printf '\t')$_title") \ + || die 5 '這台機器既沒有 sha1sum 也沒有 shasum,算不出 id。' + case "$_full" in + [0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F]*) ;; + *) die 5 "算出來的雜湊不像完整四十碼大寫十六進位:「$_full」。" ;; + esac + + [ "$_dry" -eq 1 ] || ensure_dir + + # 前綴每次多取兩碼,直到不撞。撞上的那一筆 created 與 title 都相同時不是碰撞,是同一筆 + # 被登錄兩次,回 4 並印出既有的 id。 + _len=8 + _id='' + while [ "$_len" -le 40 ]; do + _cand=$(printf '%s' "$_full" | cut -c1-"$_len") + if [ ! -f "$TASKS_DIR/$_cand" ]; then + _id="$_cand"; break + fi + _old_created=$(kv_get "$TASKS_DIR/$_cand" created) + _old_title=$(kv_get "$TASKS_DIR/$_cand" title) + if [ "$_old_created" = "$_created" ] && [ "$_old_title" = "$_title" ]; then + die 4 "這一筆已經有了:id=$_cand,建立時間與標題都相同。同一秒、同一個標題就是同一件事,不再寫一份——定期檢查項會因為清單重建而重跑登錄,多寫一筆就會讓同一個檢查每輪做兩次。要真的另立一筆,請改標題。" + fi + warn "id 前 $_len 碼撞到既有的 $_cand(那一筆的標題不同),前綴加長兩碼再試。" + _len=$((_len + 2)) + done + [ -n "$_id" ] || die 5 "完整四十碼都撞上既有檔案,而那一筆的建立時間或標題又不同。這在實務上不會發生,請人工檢查 $TASKS_DIR。" + + F_id="$_id"; F_created="$_created"; F_kind="$_kind"; F_title="$_title" + F_action="$_action"; F_trigger="$_trigger"; F_recur="$_recur"; F_repo="$_repo" + F_due="$_due" + # 一律生在 pending。生在 done 的那一筆是噪音,生在 paused 是事後才會有的人為決定。 + F_state=pending + F_last_run=''; F_next_run=''; F_fail_count=0; F_origin="$_origin" + + 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' \ + "$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" + return 0 + fi + + write_record "$TASKS_DIR/$_id" + printf 'added=%s file=%s hash40=%s prefix_len=%s\n' "$_id" "$TASKS_DIR/$_id" "$_full" "$_len" + print_record_line + # next_run 這一支不算。重複的那幾筆要有下一次的時間,由算到期的那一邊算好之後用 done + # 的 --next-run 餵回來;這裡先留空,留空的意思是「還沒排下一次」,不是「不再排」。 + case "$F_recur" in + once) ;; + *) note "這一筆是重複的(recur=$F_recur),next_run 現在留空。下一次什麼時候跑由算到期的那一邊算,算好之後用 done 的 --next-run 寫進來;這一支不算。" ;; + esac + return 0 +} + +# --- done、fail、pause、resume --- + +# 四個操作共用的取件與轉移擋人。轉移表見檔頭「狀態怎麼轉」。 +open_target() { # $1=id + resolve_record "$1" + load_record "$RECORD_FILE" +} + +cmd_done() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + _last=''; _next='' + while [ "$#" -gt 0 ]; do + case "$1" in + --last-run) [ "$#" -ge 2 ] || usage; _last="$2"; shift 2 ;; + --next-run) [ "$#" -ge 2 ] || usage; _next="$2"; shift 2 ;; + *) usage ;; + esac + done + open_target "$_id" + case "$F_state" in + pending) ;; + paused) + die 3 "id=$F_id 現在是 paused,不收 done。停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收先跑 resume $F_id,讓「解開」這件事是人做的、看得到的。" ;; + done) + die 3 "id=$F_id 已經是 done,不收第二次 done。一次性且已經收掉的那一筆不再有下一次,再 done 一次只會改寫 last_run,把一個沒發生過的執行記進去。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + + F_last_run=$(fold_and_warn last_run "${_last:-$(now_iso)}") + [ -z "$_next" ] || F_next_run=$(fold_and_warn next_run "$_next") + # 做完就把連續失敗次數歸零。留著的話,一個修好之後又跑成功的項目會一直掛著「已連續失敗 + # N 次」,那個 N 就不再是「連續」。 + F_fail_count=0 + case "$F_recur" in + once) + F_state=done ;; + *) + # 重複的那幾筆做完留在 pending,等下一次。 + F_state=pending + [ -n "$_next" ] || note "這一筆是重複的(recur=$F_recur),這一次沒有帶 --next-run,next_run 維持「${F_next_run:-空}」。下一次什麼時候跑由算到期的那一邊算,這一支不算。" ;; + esac + write_record "$RECORD_FILE" + printf 'done=%s\n' "$F_id" + print_record_line + return 0 +} + +cmd_fail() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + _last='' + while [ "$#" -gt 0 ]; do + case "$1" in + --last-run) [ "$#" -ge 2 ] || usage; _last="$2"; shift 2 ;; + *) usage ;; + esac + done + open_target "$_id" + case "$F_state" in + pending) ;; + paused) + die 3 "id=$F_id 現在是 paused,不收 fail。助理沒有在跑它,就不可能是它失敗。" ;; + done) + die 3 "id=$F_id 已經是 done,不收 fail。收掉的那一筆不再執行,記一次失敗上去會讓它看起來還在重試。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + F_last_run=$(fold_and_warn last_run "${_last:-$(now_iso)}") + F_fail_count=$((F_fail_count + 1)) + # state 一律留 pending,下一輪照重試。助理不自動轉 paused:自動停掉等於助理自己決定不做 + # 某件事,而且沒有人會發現。要讓人看到的是 fail_count,監控頁與提醒都要標「已連續失敗 + # N 次」。 + F_state=pending + write_record "$RECORD_FILE" + printf 'failed=%s fail_count=%s\n' "$F_id" "$F_fail_count" + print_record_line + note "id=$F_id 已連續失敗 $F_fail_count 次,state 留在 pending,下一輪照重試。這一筆要標進監控頁與提醒,不然一個壞掉的項目會每輪重試而沒有人知道。" + return 0 +} + +cmd_pause() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + [ "$#" -eq 0 ] || usage + open_target "$_id" + case "$F_state" in + paused) + # 同一個狀態不算轉移。擋它只會讓呼叫端為了「本來就對」的結果去分流。 + printf 'paused=%s unchanged=1\n' "$F_id" + print_record_line + return 0 ;; + pending) ;; + done) + die 3 "id=$F_id 已經是 done,不收 pause。收掉的那一筆沒有下一次可以停,停了只會讓它看起來在等人解開。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + F_state=paused + write_record "$RECORD_FILE" + printf 'paused=%s\n' "$F_id" + print_record_line + note "paused 只由人設,助理自己不設也解不開:巡檢那一輪只會叫 done 與 fail,寫不出 paused。要讓這一筆再跑就跑 resume $F_id。" + return 0 +} + +cmd_resume() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + [ "$#" -eq 0 ] || usage + open_target "$_id" + case "$F_state" in + pending) + printf 'resumed=%s unchanged=1\n' "$F_id" + print_record_line + return 0 ;; + paused) ;; + done) + die 3 "id=$F_id 已經是 done,不收 resume。它不是被停掉的,是做完收掉的;要再做一次請重新登錄一筆。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + F_state=pending + # fail_count 不歸零。它記的是真的發生過的失敗,解開一筆待辦沒有把那些失敗變成沒發生; + # 歸零會把「已連續失敗 N 次」這句提醒抹掉,而那筆待辦一恢復就會照樣再失敗一次。 + write_record "$RECORD_FILE" + printf 'resumed=%s\n' "$F_id" + print_record_line + [ "$F_fail_count" -gt 0 ] && note "id=$F_id 的 fail_count 是 $F_fail_count,解開之後刻意留著:那幾次失敗真的發生過,歸零會把「已連續失敗 N 次」這句提醒抹掉。要歸零請等它跑成功一次,done 會自己歸零。" + return 0 +} + +# --- 主流程 --- + +RECORD_FILE='' +CMD="${1:-}" +[ -n "$CMD" ] || usage +shift +case "$CMD" in + list) cmd_list "$@" ;; + add) cmd_add "$@" ;; + done) cmd_done "$@" ;; + fail) cmd_fail "$@" ;; + pause) cmd_pause "$@" ;; + resume) cmd_resume "$@" ;; + *) usage ;; +esac +exit $? From e7edfcbec0307d5e9420f39494d83255a1d7b500 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 15:46:02 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore(manifest):=20=E4=B8=89=E4=BB=BD=20man?= =?UTF-8?q?ifest=20=E5=8D=87=E7=89=88=E8=87=B3=200.1.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待辦簿工具要靠版號才傳得到機器端。 三份 manifest 由 sync-skill-manifest.sh 同步,只動版本欄位。 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c89158e..ee7f699 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.6", + "version": "0.1.7", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 57c516c..ab895cd 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.6", + "version": "0.1.7", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/plugin.json b/plugin.json index 20367e3..f73a8e9 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.6", + "version": "0.1.7", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { From 737fcf13fd097decc021d572a99c7bbb803e54e3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 16:37:44 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(due):=20=E4=BA=8B=E4=BB=B6=E5=81=B5?= =?UTF-8?q?=E6=B8=AC=E8=88=87=E5=88=B0=E6=9C=9F=E5=88=A4=E5=AE=9A=EF=BC=8C?= =?UTF-8?q?=E4=B8=83=E5=80=8B=E4=BA=8B=E4=BB=B6=E5=90=8D=E6=8E=A5=E4=B8=8A?= =?UTF-8?q?=E7=8B=80=E6=85=8B=E4=BE=86=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待辦簿存得住 trigger 與 recur,但沒有東西判「現在到期了沒有」。存放那一支的檔頭明寫它不判定、算的邏輯在別的地方,這一輪就是補那個別的地方。 另開一支而不併進存放那一支。那一支的檔頭把「我不判定」寫成合約,而合約是呼叫端唯一的依據;併進去就要推翻那整段話,而讀過舊版的人會以為那一支不判、自己再判一次,於是漂移從註解裡長出來。界線做成單向:判定這一支只讀待辦檔與印值,要改狀態一律回頭叫存放那一支的完成與失敗。 事件偵測靠比對狀態快照,不改任何產生者。每一輪讀那幾支產生者留下的狀態,跟上一輪存下的快照比,有差別就算事件發生。六個事件名各自接上本機狀態來源:工作包鎖檔轉態、階段檔換值、錯誤紀錄行數變多、工作階段開始與結束、日誌暫存區清空。 這個選擇有一個明確的代價,寫進檔頭:兩輪之間發生又消失的事件會漏掉。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的。不為了補這個洞去改那幾支產生者——要它們各自在事件發生那一刻多寫一筆,任何一支忘了寫就讓對應的事件從此不再發生,而那同樣無聲,還散在五個 domain 裡查不出是誰漏的。快照比對至少壞掉時看得出來。 第一次跑一律只建快照、不發事件,否則機器上所有既有狀態都會被當成剛發生,讓每一筆事件型待辦一次全到期。同一條理由逐個來源再套一次:某個來源上一輪不存在、這一輪才出現,那個來源這一輪也只建快照。所以快照要留一列記每個來源上一輪是看得到還是不存在——少了它就分不出「上一輪沒有這個來源」與「上一輪這個來源是空的」,而只有後者的差別才算事件。來源目錄從有變成不見時也不發事件,只記警示:整個目錄被砍掉不是每一支工作包都合併了。 四種組合一個都不壓。時鐘型的排定點從那個時間往後數、一次加一個間隔,不從上次執行起算——每次拿上次執行加間隔,執行慢幾秒就往後挪幾秒,一天下來就偏掉。漏掉的排定點只補一次不逐個追補,否則機器關一天再開會連續跑幾十輪同一件事。事件型配間隔時,那個間隔照存放那一支的定義當「重新武裝的最短間隔」。 事件型的下次執行時間一律留空。事件型沒有預定時間,唯一算得出來的是重新武裝時刻,但那個值填進去會被狀態表與提醒讀成「那個時間會跑」,它其實只是「那個時間之前不會跑」,差別在畫面上看不出來。那個值改印成另一個欄位,要看的人看得到。 規格有兩條在同一個欄位上打架。同一件事不重複觸發的判準是「事件發生時間比上次執行晚」,但失敗也會寫上次執行,於是失敗過的事件型待辦再也等不到同一次事件,也就永遠不重試——而規格明確要求失敗照重試。改用連續失敗次數當第三個判準把兩者分開:狀態還是待辦而次數大於零,代表上一次執行失敗,一律算到期,不再比事件也不看排定點。完成會把次數歸零,所以成功那一次不會被誤判成要重試。 兩項刻意標成未接線並吵出來,不假裝算得出來。分析完成那一個事件的狀態來源在 wiki 上、要連網要金鑰,混進來之後金鑰一過期就讓事件偵測整項每輪失敗;更糟的是判不出來時那幾筆會靜靜地永遠不到期,跟填一個不存在的事件名一樣看不出壞在哪。cron 式子同理,五個欄位的萬用字元與步進算錯一格就是差一小時或差一天,而那種錯要等真的跑錯才看得出來。兩者的處置都是印一行未接線、列進要人接手那一節,明說「這一筆現在判不出到期,不是還沒發生」。 間隔格式規格從來沒定義過,這一輪定成正整數加單位,刻意不收裸數字——排程週期以分鐘算、心跳門檻以秒算,裸數字兩種都讀得通,讀錯就差六十倍。 巡檢那一路一併接上:快照要有人推進才活得起來。刻意不把它算進既有五項的成敗,避免動到摘要表的欄名與本輪項目那一列的文字,舊頁那些列的欄名不會跟著改,同一張表就會有兩種寫法。失敗時改成記警示與待人處理。 順手修掉一個同類的坑:用冒號當空指令去清空檔案時,重導向失敗會讓整個殼直接結束,後面的分流一次都跑不到。這一支一律改用 true 並把理由寫進註解。 --- references/behaviors.md | 8 +- tools/due.sh | 1161 +++++++++++++++++++++++++++++++++++++++ tools/patrol.sh | 93 +++- 3 files changed, 1254 insertions(+), 8 deletions(-) create mode 100755 tools/due.sh diff --git a/references/behaviors.md b/references/behaviors.md index 1c52963..3c89b84 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -7,7 +7,7 @@ | 項目 | 內容 | | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `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` 取鎖並讀五項來源(第五項是執行狀態事件:`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-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 碼、碰撞時每次加長兩碼,六個子命令與結束碼的完整說明寫在那一支的檔頭。本技能現在只自己讀那些檔案,`tasks.sh` 的 `list`、`add`、`done`、`fail`、`pause`、`resume` 六個子命令還沒有任何一個操作呼叫得到:這一輪只把存放格式與工具定下來,到期判定、逾期與失敗處理、提醒怎麼送到前景、欄位不足時怎麼補問,一項都還沒接上去。呼叫端沒講清楚要哪一個操作時走 `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`、執行狀態事件那一項要印出本輪事件數、非 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/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`,不落在任何存取庫 | +| 關鍵步驟 | 四個操作都先跑同一個前置步驟,取得工具根目錄(本頁記成 `{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`,不落在任何存取庫 | diff --git a/tools/due.sh b/tools/due.sh new file mode 100755 index 0000000..2893512 --- /dev/null +++ b/tools/due.sh @@ -0,0 +1,1161 @@ +#!/usr/bin/env sh +# due.sh — 事件偵測與到期判定(供 jsc-assist:assistant 的 patrol 那一輪呼叫)。 +# +# 用法: +# due.sh scan [--out {目錄}] [--now {epoch 秒}] [--dry-run] +# due.sh events [--now {epoch 秒}] +# due.sh next --trigger {at:...|after:...} --recur {once|every:{間隔}} +# [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}] +# [--now {epoch 秒}] +# +# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期。 +# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。 +# next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。 +# +# 結束碼: +# 0 scan:這一輪判完了(零筆待辦、零個新事件都算判完);events:印完;next:算出來了 +# 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、 +# 快照那一段原樣留著,呼叫端要把它標成警示 +# 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、 +# --now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,其餘各筆照判 +# 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批 +# 事件會被判第二次。這一碼要吵出來,不能當成成功 +# 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」 +# 不等於「都沒到期」 +# 5 檔案系統失敗:助理狀態目錄建不起來、暫存檔寫不進去 +# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、next 缺 --trigger 或 --recur,或 +# JSC_HOME 與 HOME 都解不出絕對路徑 +# 逐筆的機器可讀輸出在 rows_file 那個定位字元分隔的檔案,欄位順序印在 rows_columns=。 +# stdout 上的 task= 那幾行只給人看:trigger、recur、why、title 都可能帶空白,切不回欄位。 +# 同一次執行同時命中好幾碼時,回報順序是 5、3、2、1、4:前面的蓋掉後面的。5 之下什麼都不算, +# 3 會讓下一輪重判同一批事件,那是最需要人知道的一件事;4 只是沒東西可判,排最後。 +# +# --- 為什麼另開一支,不併進 tasks.sh --- +# +# tasks.sh 的檔頭明寫它只負責存放與讀寫,trigger 只驗格式不算到期、recur 只驗格式不算下一次、 +# next_run 只存呼叫端算好的值。判定邏輯併進去,那句話就變成假的,而它是呼叫端唯一的依據: +# 讀的人以為那一支不判定,於是自己也判一次,兩邊各判一次就會漂移。 +# 反過來,判定這一邊也不重寫存放格式:這一支只讀待辦檔、只把算出來的值印出來,改狀態一律回去 +# 叫 tasks.sh 的 done 與 fail。所以「誰算」與「誰寫」各一支,兩支都只有一個入口。 +# +# --- 事件偵測靠比對狀態快照,不改任何產生者 --- +# +# 每一輪讀那幾支產生者留下的狀態,跟上一輪存下來的快照比,有差別就算事件發生。 +# 事件名 狀態來源 +# wp-merged:{工作包代號} $JSC_HOME/wp/*.pr 鎖檔從有變成沒有 +# stage-entered:{階段} $JSC_HOME/sessions/{sid}.stage 的值換掉 +# hook-error $JSC_HOME/errors/hooks.jsonl 行數變多 +# session-start $JSC_HOME/sessions/{sid}.start 新增或值換掉 +# session-end $JSC_HOME/sessions/{sid}.end 新增或值換掉 +# worklog-written $JSC_HOME/worklog-pending/{hash}/ 從有暫存變成沒有 +# analyze-completed:{HASH} 還沒接線,理由見下面「這一個刻意不接線」 +# +# **這個選擇有一個明確的代價:兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期 +# 之內鎖上又合併、一個工作階段在同一個週期之內開又關、暫存區清空之後又被塞回東西,這一支都 +# 看不到——它看到的只有兩張快照的差別,中間的過程沒有留下痕跡。 +# 往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。 +# +# 不為了補這個洞去改那幾支產生者。要它們各自在事件發生的那一刻多寫一筆,任何一支忘了寫就會 +# 讓對應的事件從此不再發生,而那同樣是無聲的失效——而且散在五個 domain 裡,查不出是誰漏的。 +# 快照比對至少壞掉的時候看得出來:快照檔不見了、來源讀不到、比對不出東西,這一支都會印出來。 +# +# --- 快照存哪、什麼格式 --- +# +# 兩個檔案,都在 $JSC_HOME/assistant 底下,與既有的 usage-prev.tsv、events-open.tsv 同一層、 +# 同一種命名:巡檢那一輪本來就在讀寫這個目錄,快照跟著放才不會多出第二個狀態落點。 +# events-prev.tsv 上一輪的狀態快照。三欄,定位字元分隔:{來源}、{鍵}、{值} +# events-seen.tsv 每一個事件名的累計次數與最後發生時間。三欄:{事件名}、{累計次數}、{epoch} +# 選純文字 TSV 而不選 JSON,理由同待辦簿選 key=value:讀的人不只這一支腳本,巡檢那一輪與人 +# 都會直接看它,TSV 用 awk 與 cut 就讀得準,不必多帶一個解析器。值裡的定位字元與換行寫進去 +# 之前一律折成空白,所以欄位切得乾淨。 +# 快照另外留一列 meta,記每一個來源上一輪是「看得到」還是「不存在」,理由見下一段。 +# +# --- 第一次跑不該把既有狀態都當成剛發生 --- +# +# 快照不存在的那一輪一律只建快照,不發任何事件(印 first_run=1)。第一次跑的時候,機器上所有 +# 既有狀態都是以前就發生過的事,當成剛發生會讓每一筆事件型待辦一次全到期,而那批執行沒有一次 +# 是使用者要的。 +# 同一條理由逐個來源再套一次:某個來源上一輪不存在、這一輪才出現(例如後來才裝上別的 domain, +# $JSC_HOME/wp 這才被建起來),那個來源這一輪也只建快照、不發事件。所以快照要留 meta 那幾列—— +# 少了它,分不出「上一輪沒有這個來源」與「上一輪這個來源是空的」,而後者的差別才算事件。 +# 來源目錄從「看得到」變成「不存在」時也不發事件,只記一筆警示:整個目錄被砍掉不是每一支工作包 +# 都合併了,把它當成一批 wp-merged 會一次觸發一堆待辦。 +# +# --- 這一個刻意不接線:analyze-completed --- +# +# 它的狀態來源是 wiki 上分析頁的勾選,或那一份分析的工作包議題全關,兩條都要連網、要金鑰,跟 +# 其他六個純本機檔案讀取不同性質。這一輪不接線,理由兩條: +# 一、把一個要連網的來源混進來,金鑰過期或 Gitea 連不上就會讓事件偵測整項每輪失敗,而那幾個 +# 本機來源本來一輪都不會失敗。 +# 二、更糟的是判不出來的時候,after:analyze-completed:{HASH} 那幾筆會靜靜地永遠不到期,跟填 +# 一個不存在的事件名一樣看不出壞在哪。 +# 所以這一支對那幾筆的處置是**吵出來**:印一行 unwired=、列進要人接手的那一節,明確說「這一筆 +# 現在判不出到期,不是還沒發生」。不接線與靜靜當成沒發生是兩件事,差別就在有沒有這一行。 +# +# --- cron:{式子} 也還沒接線 --- +# +# tasks.sh 收 cron:{式子},這一支不算它。cron 式子要自己實作五個欄位的萬用字元、清單與步進, +# 算錯一格就是差一小時或差一天跑一次,而那種錯要等到真的跑錯才看得出來。處置同上:標 unwired、 +# 吵出來,不假裝算得出 next_run。 +# +# --- 四種組合,一個都不壓 --- +# +# recur: once recur: every:{間隔} +# trigger: at:{時間} 那個時間做一次 從那個時間起,每隔一段做一次 +# trigger: after:{事} 下一次該事件發生時做一次 每次該事件發生都做 +# +# at 型的排定點一律從 at 那個時間往後數,一次加一個間隔:next_run 取第一個比 last_run 還晚的 +# 排定點。從 at 起算而不從 last_run 起算,是為了不漂移——每次拿 last_run 加間隔,執行慢個幾秒 +# 就往後挪幾秒,一天下來就偏掉了。 +# 漏掉的排定點只補一次,不逐個追補:機器關了一天再開,這一支算出來的是「上一次執行之後的第一個 +# 排定點」,跑完 last_run 換成當下,下一次就跳到未來那一個。追補全部漏掉的次數,開機那一刻會 +# 連續跑幾十輪同一件事。 +# 事件型配 every:{間隔} 時,那個間隔照 tasks.sh 檔頭的定義當「重新武裝的最短間隔」:事件發生了、 +# 而且距上一次執行已經超過那個間隔,才算到期。間隔還沒到就先不做,事件下一次發生時再看。 +# +# --- 同一件事不重複觸發,靠的是 last_run --- +# +# 事件型的到期判準是「那個事件最後一次發生的時間,比這一筆的 last_run 還晚」。所以同一次事件 +# 發生只會觸發一次:執行完呼叫端寫回 last_run,那個時間就追過了事件發生的時間,同一次事件再也 +# 湊不出「還沒做過」。沒跑過的那一筆拿 created 當基準線,不拿 0:一筆今天才登錄的待辦,不該被 +# 上個星期發生過的事件觸發。 +# 這條保證掛在 last_run 上,而 last_run 只有 tasks.sh 的 done 與 fail 寫得出來。呼叫端跑完不 +# 回寫,那一筆下一輪照樣到期——那是對的,它真的沒做完;但呼叫端要知道這個相依,不回寫就等於 +# 每一輪重跑同一件事。 +# 這裡有一條例外要挑明:fail 也會寫 last_run,照上面那條規則,失敗過的事件型待辦就再也等不到 +# 同一次事件了,而規格要的是「失敗下一輪照重試」。兩條規則在同一個欄位上打架,所以改用 +# fail_count 分開:state 還是 pending 而 fail_count 大於 0,代表上一次執行失敗,一律算到期, +# 不再比事件也不看排定點。done 會把 fail_count 歸零,所以成功那一次不會被誤判成要重試。 +# +# --- next_run 對事件型填什麼 --- +# +# 事件型一律留空。next_run 這一欄的語意是「下一次預定執行的時間」,而事件型沒有預定時間,它等的 +# 是一件不知道什麼時候發生的事。 +# 算得出來的只有配 every:{間隔} 時的重新武裝時刻(上一次執行加上間隔),但那個值填進 next_run 會 +# 被狀態表與提醒讀成「那個時間會跑」,它其實只是「那個時間之前不會跑」——兩者的差別在畫面上看不 +# 出來,而看不出來的錯就是無聲的錯。所以那個值改印成 rearm_at=,要看的人看得到,不寫進那一欄。 +# 留空的意思 tasks.sh 已經定義過:還沒排下一次,不是不再排。事件型永遠停在那個狀態,直到事件真的 +# 發生,這正好就是它的實情。 +# +# --- 這一支不判什麼 --- +# +# 不判逾期(due 欄有沒有過),不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。 +# 它只回答兩個問題:本輪有哪些新事件、哪幾筆現在到期。做不做、怎麼做、失敗怎麼記,都在呼叫端。 +# +# 環境變數: +# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 +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" +STATE_DIR="$JSC_HOME/assistant" +TASKS_DIR="$STATE_DIR/tasks" +CURRENT="$JSC_HOME/current" +PREV="$STATE_DIR/events-prev.tsv" +SEEN="$STATE_DIR/events-seen.tsv" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) +SCRIPT_DIR="${SCRIPT_DIR:-.}" + +TAB=$(printf '\t') + +# 結束碼一律經這裡收斂,順序見檔頭。分散在各處直接 exit 的話,一筆判不了的待辦會蓋掉「快照 +# 換不上」這種更嚴重的事,而後者才是下一輪會重判同一批事件的原因。 +RC_FS=0 +RC_SNAP=0 +RC_FIELD=0 +RC_SOURCE=0 +RC_EMPTY=0 + +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: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run] + due.sh events [--now epoch 秒] + due.sh next --trigger at:...|after:... --recur once|every:間隔 + [--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字] + [--now epoch 秒] +EOF + exit 6 +} + +# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。判準與處置同這個 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 + +# --- 時間 --- + +# 把值折成一行再塞進 TSV。定位字元與換行折成空白,前後空白去掉:不折的話一個換行就多切出一列, +# 而多出來的那一列看起來像一筆正常的快照紀錄。 +flat() { # $1=原值 + printf '%s' "$1" | tr '\n\r\t' ' ' | tr -d '\000-\037' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[[:space:]][[:space:]]*/ /g' +} + +# markdown 表格欄位裡的 `|` 會把欄切開,一律跳脫。做法與巡檢那一支的 cell() 相同。 +cell() { printf '%s' "$1" | sed 's/|/\\|/g'; } + +# ISO 時間轉 epoch。算不出來就印空字串、回 1。 +# 三條路依序試,理由是這一支跑在 cron 上,能倚賴的只有系統本來就有的東西: +# GNU date、BSD date,最後是自己算。自己算那一條不能拿掉——BusyBox 的 date 兩種旗標都不吃, +# 而少了它,那台機器上每一筆 at:{時間} 都會被判成「算不出來」,也就永遠不到期。 +iso_to_epoch() { # $1=ISO 時間字串 + [ -n "$1" ] || { printf ''; return 1; } + _e=$(date -u -d "$1" +%s 2>/dev/null) || _e='' + case "$_e" in ''|*[!0-9-]*) _e='' ;; esac + [ -n "$_e" ] && { printf '%s' "$_e"; return 0; } + _e=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null) || _e='' + case "$_e" in ''|*[!0-9-]*) _e='' ;; esac + [ -n "$_e" ] && { printf '%s' "$_e"; return 0; } + # 自己算。吃 YYYY-MM-DD 加可省略的 THH:MM:SS,尾巴可以是 Z、+HH:MM 或 -HH:MM。 + # 沒有尾巴時當成 UTC:這本簿的時間全部由 date -u 產生,帶的就是 Z。 + _e=$(printf '%s' "$1" | awk ' + function days(y, m, d, yy, era, yoe, doy, doe) { + yy = y - (m <= 2 ? 1 : 0) + era = int((yy >= 0 ? yy : yy - 399) / 400) + yoe = yy - era * 400 + doy = int((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1 + doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy + return era * 146097 + doe - 719468 + } + { + s = $0 + if (match(s, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/) == 0) exit 1 + y = substr(s, 1, 4) + 0; mo = substr(s, 6, 2) + 0; d = substr(s, 9, 2) + 0 + if (mo < 1 || mo > 12 || d < 1 || d > 31) exit 1 + hh = 0; mi = 0; ss = 0; off = 0 + r = substr(s, 11) + if (match(r, /^[T ][0-9][0-9]:[0-9][0-9]/)) { + hh = substr(r, 2, 2) + 0; mi = substr(r, 5, 2) + 0 + if (match(substr(r, 7), /^:[0-9][0-9]/)) { ss = substr(r, 8, 2) + 0; r = substr(r, 10) } + else r = substr(r, 7) + if (match(r, /^[+-][0-9][0-9]:?[0-9][0-9]$/)) { + sg = (substr(r, 1, 1) == "-" ? -1 : 1) + oh = substr(r, 2, 2) + 0 + om = substr(r, length(r) - 1, 2) + 0 + off = sg * (oh * 3600 + om * 60) + } + } + if (hh > 23 || mi > 59 || ss > 60) exit 1 + printf "%d", days(y, mo, d) * 86400 + hh * 3600 + mi * 60 + ss - off + }' 2>/dev/null) || _e='' + case "$_e" in ''|*[!0-9-]*) printf ''; return 1 ;; esac + printf '%s' "$_e" + return 0 +} + +epoch_to_iso() { # $1=epoch 秒 + case "$1" in ''|*[!0-9]*) printf ''; return 0 ;; esac + date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null && return 0 + date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null && return 0 + printf '%s' "$1" +} + +# every:{間隔} 的間隔轉秒。收「正整數加單位」,單位是 s、m、h、d、w(秒、分、時、日、週)。 +# 不收裸數字:這個技能組裡的排程週期以分鐘為單位、心跳門檻以秒為單位,裸數字兩種都讀得通, +# 而讀錯就是差六十倍,那種錯要等到跑得太密或太疏才看得出來。所以一定要帶單位。 +interval_secs() { # $1=every: 後面那一段 + _n=$(printf '%s' "$1" | sed -n 's/^\([0-9][0-9]*\)[smhdw]$/\1/p') + [ -n "$_n" ] || { printf ''; return 1; } + [ "$_n" -ge 1 ] || { printf ''; return 1; } + case "$1" in + *s) printf '%s' "$_n" ;; + *m) printf '%s' "$(( _n * 60 ))" ;; + *h) printf '%s' "$(( _n * 3600 ))" ;; + *d) printf '%s' "$(( _n * 86400 ))" ;; + *w) printf '%s' "$(( _n * 604800 ))" ;; + *) printf ''; return 1 ;; + esac + return 0 +} + +# --- 快照的讀寫 --- + +# 取快照裡某一列的值。來源與鍵都當字面比對,不用正規表示式:鍵裡有工作包代號與雜湊,那些字 +# 在正規表示式裡有別的意思。 +snap_get() { # $1=來源 $2=鍵 + [ -f "$PREV" ] || return 1 + awk -F'\t' -v s="$1" -v k="$2" '$1 == s && $2 == k { print $3; found = 1; exit } END { exit found ? 0 : 1 }' "$PREV" 2>/dev/null +} + +snap_meta() { # $1=來源;上一輪這個來源是 present、absent 還是 unreadable,沒有那一列就印空 + snap_get meta "$1" 2>/dev/null || printf '' +} + +# 事件計數。印「累計次數 最後發生 epoch」,沒有那一列就印「0 」。 +seen_get() { # $1=事件名 + [ -f "$SEEN" ] || { printf '0 '; return 0; } + awk -F'\t' -v e="$1" '$1 == e { printf "%s %s", $2, $3; found = 1; exit } END { if (!found) printf "0 " }' "$SEEN" 2>/dev/null +} + +# --- 狀態來源逐個掃 --- +# +# 每一個來源都做同一件事:把現在的狀態寫成快照的幾列(收到 $NEXT),跟上一輪比出事件(收到 +# $FIRED,一行一個事件名)。來源不存在、第一次看到、讀不到,三種各有處置,見檔頭。 + +NEXT='' +FIRED='' +SRC_LINES='' + +add_src() { # $1=來源 $2=狀態 $3=筆數 $4=說明 + SRC_LINES="${SRC_LINES}source=$1 status=$2 count=$3 note=$4 +" +} + +fire() { # $1=事件名 $2=一句話講完哪裡看出來的 + printf '%s\t%s\n' "$1" "$(flat "$2")" >>"$FIRED" +} + +put() { # $1=來源 $2=鍵 $3=值 + printf '%s\t%s\t%s\n' "$1" "$(flat "$2")" "$(flat "$3")" >>"$NEXT" +} + +# 目錄能不能讀。回 0 讀得到、1 不存在、2 存在但讀不到。 +dir_state() { # $1=目錄 + [ -d "$1" ] || return 1 + [ -r "$1" ] && [ -x "$1" ] || return 2 + return 0 +} + +# 來源不存在或讀不到時共用的收尾。回 1 代表這個來源這一輪不用再往下掃。 +src_guard() { # $1=來源名 $2=路徑 $3=dir_state 的結果 $4=人看的來源說明 + case "$3" in + 1) + put meta "$1" absent + add_src "$1" absent 0 "來源不存在($2)" + # 上一輪看得到、這一輪整個不見了:不發事件。整個目錄被砍掉不是每一筆都完成了。 + if [ "$(snap_meta "$1")" = present ]; then + warn "$4 上一輪還在($2),這一輪整個不見了。這一輪不發任何事件:整個來源消失不等於裡面每一筆都完成了,當成完成會一次觸發一堆待辦。要是那個目錄是被誤刪的,事件從此就少了一種。" + fi + return 1 ;; + 2) + # 讀不到就把上一輪那幾列原樣留著。改寫成空的話,下一輪會把「原本就在」的東西比成新增, + # 或把讀不到那一輪的空白比成一批消失,兩種都是假事件。 + # meta 那一列改成 unreadable,於是權限修好之後的那一輪走「第一次看得到」那一條,只建 + # 快照、不發事件。代價是讀不到那段時間裡真的發生過的事會漏掉;換來的是不會在恢復的那 + # 一瞬間一次比出一批假事件。漏掉這件事本來就是快照比對的既有代價,見檔頭。 + if [ -f "$PREV" ]; then + awk -F'\t' -v s="$1" '$1 == s' "$PREV" >>"$NEXT" 2>/dev/null + fi + put meta "$1" unreadable + add_src "$1" unreadable 0 "來源讀不到($2)" + RC_SOURCE=1 + warn "$4 讀不到($2),權限或掛載有問題。這一輪不比對這個來源,快照那幾列原樣留著;改寫成空的會在下一輪比出一批假事件。" + return 1 ;; + esac + return 0 +} + +# wp-merged:{工作包代號} — 鎖檔從有變成沒有。 +# 鎖檔只有一條自動消失的路:工作包閘門查到 PR 已合併,順手解鎖。人手動解鎖也會讓它消失,那一次 +# 會誤報一次 wp-merged。要分辨就得連網查那支 PR 的狀態,那又把本機比對變成連網比對,代價見檔頭 +# 對 analyze-completed 的說明。所以照實記在這裡:鎖檔消失就當合併。 +src_wp() { + _d="$JSC_HOME/wp" + dir_state "$_d"; _ds=$? + src_guard wp "$_d" "$_ds" '工作包鎖目錄' || return 0 + + _n=0 + for _f in "$_d"/*.pr; do + [ -f "$_f" ] || continue + _k=$(basename "$_f" .pr) + _wp=$(sed -n 's/^wp=//p' "$_f" 2>/dev/null | head -n1) + [ -n "$_wp" ] || _wp='-' + put wp "$_k" "$_wp" + _n=$(( _n + 1 )) + done + put meta wp present + add_src wp ok "$_n" '鎖檔數' + + [ "$(snap_meta wp)" = present ] || { + note "工作包鎖目錄這一輪第一次看得到($_d),只建快照、不發事件。既有的鎖檔是以前就鎖上的,當成剛合併會一次觸發一堆待辦。" + return 0 + } + # 上一輪有、這一輪沒有的那幾支。 + awk -F'\t' -v tab="$TAB" ' + FILENAME == ARGV[1] { if ($1 == "wp") now[$2] = 1; next } + $1 == "wp" && !($2 in now) { print $2 tab $3 } + ' "$NEXT" "$PREV" 2>/dev/null | while IFS="$TAB" read -r _k _wp; do + [ -n "$_k" ] || continue + if [ "$_wp" = '-' ] || [ -z "$_wp" ]; then + warn "工作包鎖檔 $_k.pr 不見了(多半是那支 PR 合併了),但那個鎖檔沒有 wp= 欄位,算不出事件名要接哪一個工作包代號,所以這一輪不發事件。鎖上的時候帶 --wp 就不會有這個洞。" + continue + fi + fire "wp-merged:$_wp" "鎖檔 $_k.pr 從有變成沒有" + done + return 0 +} + +# stage-entered:{階段} — .stage 的值換掉。第一欄才是階段,後面兩欄是必要標籤與上鎖時的模型。 +src_stage() { + _d="$JSC_HOME/sessions" + dir_state "$_d"; _ds=$? + src_guard stage "$_d" "$_ds" '工作階段狀態目錄' || return 0 + + _n=0 + for _f in "$_d"/*.stage; do + [ -f "$_f" ] && [ -r "$_f" ] || continue + _k=$(basename "$_f" .stage) + _v=$(cut -f1 "$_f" 2>/dev/null | head -n1) + [ -n "$_v" ] || _v='-' + put stage "$_k" "$_v" + _n=$(( _n + 1 )) + done + put meta stage present + add_src stage ok "$_n" '階段鎖數' + + [ "$(snap_meta stage)" = present ] || { + note "工作階段狀態目錄這一輪第一次看得到($_d),階段這一項只建快照、不發事件。" + return 0 + } + # 值換掉或這一輪才出現的,都算進入了那個階段。階段離開(檔案消失)不是事件:詞彙表裡沒有 + # 「離開某階段」這個事件名,硬塞一個進去就是自由填字串,那正是詞彙表要擋的事。 + awk -F'\t' -v tab="$TAB" ' + FILENAME == ARGV[1] { if ($1 == "stage") old[$2] = $3; next } + $1 == "stage" && $3 != "-" && (!($2 in old) || old[$2] != $3) { print $2 tab $3 } + ' "$PREV" "$NEXT" 2>/dev/null | while IFS="$TAB" read -r _k _v; do + [ -n "$_v" ] || continue + fire "stage-entered:$_v" "工作階段 $_k 的階段換成 $_v" + done + return 0 +} + +# session-start、session-end — .start 與 .end 新增或值換掉。 +# .end 消失不是事件:同一個工作階段重新開場時,計時那一支會把 .end 刪掉,那一刻真正發生的是 +# session-start,已經由 .start 的值換掉抓到了。 +src_session() { + _d="$JSC_HOME/sessions" + dir_state "$_d"; _ds=$? + src_guard sess "$_d" "$_ds" '工作階段計時目錄' || return 0 + + _n=0 + for _sfx in start end; do + for _f in "$_d"/*."$_sfx"; do + [ -f "$_f" ] && [ -r "$_f" ] || continue + _k="$(basename "$_f" ".$_sfx").$_sfx" + _v=$(head -n1 "$_f" 2>/dev/null) + [ -n "$_v" ] || _v='-' + put sess "$_k" "$_v" + _n=$(( _n + 1 )) + done + done + put meta sess present + add_src sess ok "$_n" '計時檔數' + + [ "$(snap_meta sess)" = present ] || { + note "工作階段計時目錄這一輪第一次看得到($_d),開始與結束這兩項只建快照、不發事件。" + return 0 + } + awk -F'\t' -v tab="$TAB" ' + FILENAME == ARGV[1] { if ($1 == "sess") old[$2] = $3; next } + $1 == "sess" && (!($2 in old) || old[$2] != $3) { print $2 tab $3 } + ' "$PREV" "$NEXT" 2>/dev/null | while IFS="$TAB" read -r _k _v; do + [ -n "$_k" ] || continue + case "$_k" in + *.start) fire session-start "工作階段 ${_k%.start} 的 .start 新增或換值" ;; + *.end) fire session-end "工作階段 ${_k%.end} 的 .end 新增或換值" ;; + esac + done + return 0 +} + +# hook-error — errors/hooks.jsonl 行數變多。 +# 事件名不帶參數,所以一輪只發一次,不管這一輪新增了幾列。行數變少一律不發事件:那是檔案被 +# 輪替或截斷,不是錯誤消失了;把它當成事件就是把「有人清了紀錄」報成「又出錯了」。 +src_hookerr() { + _f="$JSC_HOME/errors/hooks.jsonl" + if [ ! -f "$_f" ]; then + put meta hookerr absent + add_src hookerr absent 0 "來源不存在($_f)" + [ "$(snap_meta hookerr)" = present ] \ + && warn "hook 錯誤紀錄上一輪還在($_f),這一輪不見了。這一輪不發事件:檔案消失不是錯誤消失。" + return 0 + fi + if [ ! -r "$_f" ]; then + if [ -f "$PREV" ]; then + awk -F'\t' '$1 == "hookerr"' "$PREV" >>"$NEXT" 2>/dev/null + fi + put meta hookerr unreadable + add_src hookerr unreadable 0 "來源讀不到($_f)" + RC_SOURCE=1 + warn "hook 錯誤紀錄讀不到($_f)。這一輪不比對,快照那一列原樣留著。" + return 0 + fi + + _now_lines=$(wc -l <"$_f" 2>/dev/null | tr -d ' ') + case "$_now_lines" in ''|*[!0-9]*) _now_lines=0 ;; esac + put hookerr lines "$_now_lines" + put meta hookerr present + add_src hookerr ok "$_now_lines" '紀錄列數' + + [ "$(snap_meta hookerr)" = present ] || { + note "hook 錯誤紀錄這一輪第一次看得到($_f,共 $_now_lines 列),只建快照、不發事件。既有的那幾列是以前就記下的。" + return 0 + } + _old_lines=$(snap_get hookerr lines 2>/dev/null) || _old_lines='' + case "$_old_lines" in ''|*[!0-9]*) _old_lines=0 ;; esac + if [ "$_now_lines" -gt "$_old_lines" ]; then + fire hook-error "hook 錯誤紀錄從 $_old_lines 列變成 $_now_lines 列" + elif [ "$_now_lines" -lt "$_old_lines" ]; then + warn "hook 錯誤紀錄從 $_old_lines 列變成 $_now_lines 列,變少了(多半是被輪替或截斷)。這一輪不發事件,下一輪拿新的列數當基準。" + fi + return 0 +} + +# worklog-written — 某個雜湊的暫存區從有暫存變成沒有。 +# 暫存區清空那一刻就是日誌寫進 wiki 那一刻:清空只在寫入成功之後才做,寫失敗時暫存一律留著。 +# 所以「從有變成沒有」比「有沒有那個目錄」準:目錄可能一直在,只是裡面空了。 +src_worklog() { + _d="$JSC_HOME/worklog-pending" + dir_state "$_d"; _ds=$? + src_guard worklog "$_d" "$_ds" '工作日誌暫存目錄' || return 0 + + _n=0 + for _sub in "$_d"/*; do + [ -d "$_sub" ] || continue + _k=$(basename "$_sub") + # .merge 是合併檔的落點,不是某一個雜湊的暫存區,比對它會把一次合併報成一次寫入。 + [ "$_k" = '.merge' ] && continue + _c=0 + for _pf in "$_sub"/*; do + [ -f "$_pf" ] || continue + _c=$(( _c + 1 )) + done + put worklog "$_k" "$_c" + _n=$(( _n + 1 )) + done + put meta worklog present + add_src worklog ok "$_n" '暫存區數' + + [ "$(snap_meta worklog)" = present ] || { + note "工作日誌暫存目錄這一輪第一次看得到($_d),只建快照、不發事件。" + return 0 + } + # 上一輪大於 0、這一輪是 0 或整個目錄不見了,都算清空。 + awk -F'\t' -v tab="$TAB" ' + FILENAME == ARGV[1] { if ($1 == "worklog") now[$2] = $3; next } + $1 == "worklog" && $3 + 0 > 0 { + if (!($2 in now)) { print $2 tab "0"; next } + if (now[$2] + 0 == 0) print $2 tab now[$2] + } + ' "$NEXT" "$PREV" 2>/dev/null | while IFS="$TAB" read -r _k _c; do + [ -n "$_k" ] || continue + fire worklog-written "工作日誌暫存區 $_k 從有暫存變成沒有" + done + return 0 +} + +# analyze-completed:{HASH} — 還沒接線,理由見檔頭。這裡只把它記成 unwired,讓它在輸出上看得到。 +src_analyze() { + add_src analyze unwired 0 '狀態來源在 wiki 的分析頁上,要連網才判得出來,這一輪沒有接線' + return 0 +} + +# --- 事件偵測一輪 --- + +# $1=1 代表要推進快照與事件計數;0 是唯讀預覽。 +detect() { # $1=是否寫回 + _write="$1" + _tmp="$STATE_DIR/.due.$$" + mkdir -p "$STATE_DIR" 2>/dev/null || { RC_FS=5; die 5 "建不出助理狀態目錄 $STATE_DIR。"; } + NEXT="$_tmp.next" + FIRED="$_tmp.fired" + # 清空一個檔案一律寫成 true >檔案,不寫成 : >檔案。冒號是 POSIX 的特殊內建指令,重導向 + # 失敗時整個殼直接結束,後面那個 || 一次都不會跑到——於是「暫存檔寫不進去」會回成殼自己的 + # 結束碼 2,而這一支的 2 是「欄位值不合法」,呼叫端會照那一碼去叫人改待辦,方向完全錯。 + # true 是普通內建指令,重導向失敗只讓它自己回非 0,|| 那一段才接得到。 + true >"$NEXT" 2>/dev/null || { RC_FS=5; die 5 "暫存檔寫不進去:$NEXT。"; } + true >"$FIRED" 2>/dev/null || { RC_FS=5; die 5 "暫存檔寫不進去:$FIRED。"; } + + FIRST_RUN=0 + if [ ! -f "$PREV" ]; then + FIRST_RUN=1 + note "找不到上一輪的狀態快照($PREV),這一輪只建快照、不發任何事件。第一次跑的時候機器上所有既有狀態都是以前就發生過的事,當成剛發生會讓每一筆事件型待辦一次全到期。" + fi + + src_wp + src_stage + src_session + src_hookerr + src_worklog + src_analyze + + # 第一輪把偵測結果整批丟掉。上面每一個來源自己也有「第一次看到」的擋法,這一道是總開關: + # 快照檔整個不見(被誤刪、換機器)時,每一個來源都會走到第一次看到那一條,但擋在這裡比較 + # 顯眼——輸出上有一行 first_run=1,看的人立刻知道這一輪為什麼零事件。 + [ "$FIRST_RUN" -eq 1 ] && true >"$FIRED" + + # 同一輪同一個事件名發生好幾次(兩個工作階段同時進同一個階段)只算一個事件名,但累計次數 + # 照實加:次數要對得上真的發生幾次,不然它就只是一個「有沒有發生過」的旗標。 + EV_NAMES=$(LC_ALL=C cut -f1 "$FIRED" 2>/dev/null | LC_ALL=C sort -u) + EV_NEW=0 + [ -n "$EV_NAMES" ] && EV_NEW=$(printf '%s\n' "$EV_NAMES" | grep -c .) + case "$EV_NEW" in ''|*[!0-9]*) EV_NEW=0 ;; esac + + if [ "$_write" -eq 1 ]; then + # 事件計數先算成一份新的,再整份換上去。半途改寫的話,換到一半失敗會留下一份少了幾列的 + # 計數檔,而那幾列少掉之後,對應的事件型待辦會退回「沒發生過」。 + _sn="$_tmp.seen" + [ -f "$SEEN" ] || true >"$SEEN" 2>/dev/null + awk -F'\t' -v now="$NOW" -v tab="$TAB" ' + FILENAME == ARGV[1] { cnt[$1]++; next } + { old[$1] = $2 tab $3 } + END { + for (e in cnt) { + n = 0 + if (e in old) { split(old[e], p, tab); n = p[1] + 0 } + printf "%s\t%d\t%s\n", e, n + cnt[e], now + } + for (e in old) { + if (e in cnt) continue + split(old[e], p, tab) + printf "%s\t%s\t%s\n", e, p[1], p[2] + } + } + ' "$FIRED" "$SEEN" 2>/dev/null | LC_ALL=C sort >"$_sn" + if ! mv "$_sn" "$SEEN" 2>/dev/null; then + RC_SNAP=3 + rm -f "$_sn" + warn "事件計數換不上去($SEEN)。這一輪的事件算出來了,但下一輪會拿舊的計數比,同一批事件會被判第二次。" + fi + if ! mv "$NEXT" "$PREV" 2>/dev/null; then + RC_SNAP=3 + warn "狀態快照換不上去($PREV)。這一輪的事件算出來了,但下一輪會拿同一份舊快照再比一次,同一批事件會被判第二次。" + fi + fi + return 0 +} + +print_events() { + printf 'first_run=%s\n' "$FIRST_RUN" + printf '%s' "$SRC_LINES" + if [ -s "$FIRED" ]; then + printf '%s\n' "$EV_NAMES" | while IFS= read -r _e; do + [ -n "$_e" ] || continue + _c=$(awk -F'\t' -v e="$_e" '$1 == e { n++ } END { printf "%d", n + 0 }' "$FIRED" 2>/dev/null) + _w=$(awk -F'\t' -v e="$_e" '$1 == e { print $2; exit }' "$FIRED" 2>/dev/null) + printf 'event=%s times=%s at=%s note=%s\n' "$_e" "$_c" "$(epoch_to_iso "$NOW")" "$_w" + done + fi + printf 'events_new=%s\n' "$EV_NEW" +} + +# --- 到期判定 --- + +# 算一筆的 next_run 與到不到期。設好 R_* 那幾個變數,回 0;欄位判不了時設 R_VERDICT=invalid +# 或 unwired,一樣回 0——一筆判不了不該讓其餘各筆停下。 +# +# R_VERDICT due(到期)、wait(還沒到)、invalid(欄位判不了)、unwired(來源還沒接線) +# R_NEXT next_run 該填的值;事件型一律空,理由見檔頭 +# R_REARM 事件型配間隔時的重新武裝時刻;其餘為空 +# R_WHY 一句話講完為什麼是這個判定 +judge() { # $1=trigger $2=recur $3=created $4=last_run $5=fail_count + R_VERDICT=''; R_NEXT=''; R_REARM=''; R_WHY='' + _tg="$1"; _rc="$2"; _cr="$3"; _lr="$4"; _fc="$5" + case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac + + # 間隔先解。cron 這一條在這裡就攔下:它與 trigger 的兩種型別都配得起來,不先攔就會兩邊各 + # 寫一次「還沒接線」。 + _iv='' + case "$_rc" in + once) ;; + every:*) + _iv=$(interval_secs "${_rc#every:}") || { + R_VERDICT=invalid + R_WHY="recur「$_rc」的間隔解不開。只收正整數加單位 s、m、h、d、w(秒、分、時、日、週),例如 every:15m、every:1d。裸數字一律不收:這個技能組裡排程週期以分鐘算、心跳門檻以秒算,裸數字兩種都讀得通,讀錯就差六十倍。" + return 0 + } ;; + cron:*) + R_VERDICT=unwired + R_WHY="recur「$_rc」還沒接線。cron 式子要自己實作五個欄位的萬用字元、清單與步進,算錯一格就是差一小時或差一天跑一次。這一筆現在算不出 next_run,不是還沒到期。" + return 0 ;; + *) + R_VERDICT=invalid + R_WHY="recur「$_rc」不合法。只收 once、every:{間隔} 或 cron:{式子}。" + return 0 ;; + esac + + # 上一次執行失敗過就一律算到期,不再比事件也不看排定點。理由見檔頭「同一件事不重複觸發」 + # 最後那一段:不重複觸發與失敗照重試兩條規則在 last_run 這一欄上打架,靠 fail_count 分開。 + _lre='' + if [ -n "$_lr" ]; then + _lre=$(iso_to_epoch "$_lr") || _lre='' + [ -n "$_lre" ] || { + R_VERDICT=invalid + R_WHY="last_run「$_lr」解不出時間。這一筆的上一次執行時間不可信,算不出下一次;請重新登錄一筆。" + return 0 + } + fi + + # last_run 比現在還晚,那是時鐘被往回調過、或那個值是手填的。這種狀態不當成到期:拿一個 + # 未來的時間當基準線,算出來的間隔是負數,畫面上會印出「已經過了負幾百秒」那種讀不懂的字, + # 而真正的問題(時鐘不對)反而被藏在裡面。所以單獨判一條,把時鐘這件事講出來。 + # 失敗過的那一筆例外,照重試優先:時鐘不對不影響「上一次真的失敗了」這件事。 + _future=0 + [ -n "$_lre" ] && [ "$_lre" -gt "$NOW" ] && _future=1 + + case "$_tg" in + at:*) + _base='' + _at="${_tg#at:}" + if [ "$_at" = now ]; then + # at:now 的排定點取建立時間:登錄那一刻就是「立刻要做」的那一刻。取當下時間的話, + # 每一輪都算出一個新的排定點,那一筆就永遠是剛剛才到期,也永遠算不出下一次。 + _base=$(iso_to_epoch "$_cr") || _base='' + [ -n "$_base" ] || { + R_VERDICT=invalid + R_WHY="trigger 是 at:now,但 created「$_cr」解不出時間,算不出第一次的排定點。" + return 0 + } + else + _base=$(iso_to_epoch "$_at") || _base='' + [ -n "$_base" ] || { + R_VERDICT=invalid + R_WHY="trigger「$_tg」的時間解不開。只收 ISO 時間(例如 at:2026-09-05T09:00:00Z)或 at:now。" + return 0 + } + fi + + if [ -z "$_lre" ]; then + _n="$_base" + elif [ -z "$_iv" ]; then + # 一次性且跑過了。這一筆該由 tasks.sh 的 done 收成 done,還留在 pending 只有一種情況: + # 上一次是 fail。所以 next_run 留空,到不到期交給下面那條重試判準。 + _n='' + else + _d=$(( _lre - _base )) + if [ "$_d" -lt 0 ]; then _n="$_base"; else _n=$(( _base + ( _d / _iv + 1 ) * _iv )); fi + fi + R_NEXT=$(epoch_to_iso "$_n") + + if [ "$_fc" -gt 0 ]; then + R_VERDICT=due + R_WHY="上一次執行失敗(fail_count=$_fc),照重試,不等下一個排定點。" + return 0 + fi + if [ "$_future" -eq 1 ]; then + R_VERDICT=wait + R_WHY="last_run($_lr)比現在($(epoch_to_iso "$NOW"))還晚。這一筆先不判到期:時鐘被往回調過,或那個值是手填的。時鐘校正之後這一筆自己就會恢復。" + return 0 + fi + if [ -z "$_n" ]; then + R_VERDICT=wait + R_WHY='一次性的項目已經跑過了,沒有下一個排定點。' + return 0 + fi + if [ "$_n" -le "$NOW" ]; then + R_VERDICT=due + R_WHY="排定點 $(epoch_to_iso "$_n") 已經到了或過了。" + else + R_VERDICT=wait + R_WHY="下一個排定點是 $(epoch_to_iso "$_n"),還沒到。" + fi + return 0 ;; + + after:*) + _ev="${_tg#after:}" + case "$_ev" in + analyze-completed:*) + R_VERDICT=unwired + R_WHY="事件「$_ev」的狀態來源在 wiki 的分析頁上,要連網才判得出來,這一輪沒有接線。這一筆現在判不出到期,不是還沒發生——不吵出來的話它會看起來就是一筆正常等著的待辦。" + return 0 ;; + worklog-written|hook-error|session-start|session-end|wp-merged:?*|stage-entered:?*) ;; + *) + R_VERDICT=invalid + R_WHY="事件名「$_ev」不在固定詞彙表上。只認 worklog-written、hook-error、session-start、session-end、wp-merged:{工作包代號}、stage-entered:{階段}、analyze-completed:{HASH}。" + return 0 ;; + esac + + # 事件型的 next_run 一律留空,理由見檔頭。重新武裝時刻另外印成 rearm_at。 + _sg=$(seen_get "$_ev") + _evat=$(printf '%s' "$_sg" | awk '{ print $2 }') + case "$_evat" in ''|*[!0-9]*) _evat='' ;; esac + + if [ -n "$_iv" ] && [ -n "$_lre" ]; then + R_REARM=$(epoch_to_iso "$(( _lre + _iv ))") + fi + + if [ "$_fc" -gt 0 ]; then + R_VERDICT=due + R_WHY="上一次執行失敗(fail_count=$_fc),照重試,不等事件再發生一次。" + return 0 + fi + if [ "$_future" -eq 1 ]; then + R_VERDICT=wait + R_WHY="last_run($_lr)比現在($(epoch_to_iso "$NOW"))還晚。這一筆先不判到期:時鐘被往回調過,或那個值是手填的。時鐘校正之後這一筆自己就會恢復。" + return 0 + fi + + # 沒跑過的那一筆拿 created 當基準線,不拿 0:一筆今天才登錄的待辦,不該被上個星期發生 + # 過的事件觸發。created 解不開就判不了——沒有基準線就分不出事件是登錄之前還是之後發生的。 + if [ -n "$_lre" ]; then + _bl="$_lre" + else + _bl=$(iso_to_epoch "$_cr") || _bl='' + [ -n "$_bl" ] || { + R_VERDICT=invalid + R_WHY="created「$_cr」解不出時間,算不出事件的基準線,分不出事件是這一筆登錄之前還是之後發生的。" + return 0 + } + fi + + if [ -z "$_evat" ]; then + R_VERDICT=wait + R_WHY="事件「$_ev」到現在一次都沒被偵測到。" + return 0 + fi + if [ "$_evat" -le "$_bl" ]; then + R_VERDICT=wait + R_WHY="事件「$_ev」最後一次發生是 $(epoch_to_iso "$_evat"),不比基準線 $(epoch_to_iso "$_bl") 晚,同一次發生不重複觸發。" + return 0 + fi + if [ -n "$_iv" ] && [ -n "$_lre" ] && [ "$(( NOW - _lre ))" -lt "$_iv" ]; then + R_VERDICT=wait + R_WHY="事件「$_ev」發生過了,但距上一次執行還不到重新武裝的最短間隔(要 $_iv 秒,現在 $(( NOW - _lre )) 秒),$R_REARM 之後才算到期。" + return 0 + fi + R_VERDICT=due + R_WHY="事件「$_ev」在 $(epoch_to_iso "$_evat") 發生,比基準線 $(epoch_to_iso "$_bl") 晚。" + return 0 ;; + + *) + R_VERDICT=invalid + R_WHY="trigger「$_tg」不合法。只收 at:{ISO 時間}、at:now,或 after:{事件名}。" + return 0 ;; + esac +} + +kv_get() { # $1=檔案 $2=鍵 + tr -d '\r' <"$1" 2>/dev/null | sed -n "s/^$2=//p" | head -n1 +} + +# --- scan --- + +# 逐筆判定寫成定位字元分隔的一列,欄位順序固定十一個: +# id、判定、state、kind、action、trigger、recur、next_run、rearm_at、why、title +# 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是 +# recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位 +# 字元也不會有換行,所以定位字元切得準。 +row_out() { # 十一個欄位,順序如上 + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(flat "$1")" "$(flat "$2")" "$(flat "$3")" "$(flat "$4")" "$(flat "$5")" \ + "$(flat "$6")" "$(flat "$7")" "$(flat "$8")" "$(flat "$9")" \ + "$(flat "${10}")" "$(flat "${11}")" >>"$ROWS" +} + +scan_tasks() { + T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0 + true >"$ROWS" 2>/dev/null || { RC_FS=5; die 5 "逐筆判定的輸出檔寫不進去:$ROWS。"; } + if [ ! -d "$TASKS_DIR" ]; then + RC_EMPTY=4 + note "待辦簿目錄不存在($TASKS_DIR),這一輪沒有任何一筆可判。這不是失敗,助理還沒收過待辦就是這個狀態;但「沒判過」不等於「都沒到期」。" + return 0 + fi + for _f in "$TASKS_DIR"/*; do + [ -f "$_f" ] || continue + T_TOTAL=$(( T_TOTAL + 1 )) + _id=$(kv_get "$_f" id) + [ -n "$_id" ] || _id=$(basename "$_f") + _st=$(kv_get "$_f" state); [ -n "$_st" ] || _st=pending + _kd=$(kv_get "$_f" kind) + _ti=$(kv_get "$_f" title) + _ac=$(kv_get "$_f" action) + _tg=$(kv_get "$_f" trigger) + _rc=$(kv_get "$_f" recur) + _cr=$(kv_get "$_f" created) + _lr=$(kv_get "$_f" last_run) + _fc=$(kv_get "$_f" fail_count) + + if [ "$_st" != pending ]; then + # done 與 paused 一律不判。paused 是人設的,助理判它到期只會讓人以為它還在跑。 + T_SKIP=$(( T_SKIP + 1 )) + row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" + continue + fi + + judge "$_tg" "$_rc" "$_cr" "$_lr" "$_fc" + case "$R_VERDICT" in + due) T_DUE=$(( T_DUE + 1 )) ;; + wait) T_WAIT=$(( T_WAIT + 1 )) ;; + invalid) T_INVALID=$(( T_INVALID + 1 )); RC_FIELD=2 ;; + unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;; + esac + row_out "$_id" "$R_VERDICT" "$_st" "$_kd" "$_ac" "$_tg" "$_rc" \ + "$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" + done + [ "$T_TOTAL" -eq 0 ] && { + RC_EMPTY=4 + note "待辦簿目錄在($TASKS_DIR),但零筆,這一輪沒有任何一筆可判。" + } + return 0 +} + +# 監控頁那一節。這一支一頁都不寫,只把 markdown 組成檔案:那一段有好幾百字,經過對話重打一次 +# 只會多錯字,理由同巡檢那一支把三塊寫成檔案。 +compose_md() { + { + printf '### 待辦簿到期與逾期\n\n' + printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \ + "$PREV" "$SEEN" + printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n' + printf '這一節不判逾期(`due` 欄有沒有過),也不執行任何一筆。到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n' + printf '| 項目 | 內容 |\n' + printf '| --- | --- |\n' + printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")" + if [ "$FIRST_RUN" -eq 1 ]; then + printf '| 快照 | 上一輪的快照不存在,這一輪只建快照、不發任何事件 |\n' + else + printf '| 快照 | 比對過上一輪的快照 |\n' + fi + printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW" + printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL" + printf '| 到期 | %s |\n' "$T_DUE" + printf '| 還沒到 | %s |\n' "$T_WAIT" + printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID" + printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED" + printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP" + + printf '\n#### 狀態來源\n\n' + printf '| 來源 | 狀態 | 筆數 | 說明 |\n' + printf '| --- | --- | ---: | --- |\n' + printf '%s' "$SRC_LINES" | while IFS= read -r _l; do + [ -n "$_l" ] || continue + _s=$(printf '%s' "$_l" | sed -n 's/^source=\([^ ]*\).*/\1/p') + _t=$(printf '%s' "$_l" | sed -n 's/.* status=\([^ ]*\).*/\1/p') + _c=$(printf '%s' "$_l" | sed -n 's/.* count=\([^ ]*\).*/\1/p') + _nt=$(printf '%s' "$_l" | sed -n 's/.* note=//p') + printf '| %s | %s | %s | %s |\n' "$_s" "$_t" "$_c" "$_nt" + done + + printf '\n#### 本輪新事件\n\n' + if [ "$EV_NEW" -gt 0 ]; then + printf '| 事件 | 本輪發生次數 | 累計次數 | 哪裡看出來的 |\n' + printf '| --- | ---: | ---: | --- |\n' + printf '%s\n' "$EV_NAMES" | while IFS= read -r _e; do + [ -n "$_e" ] || continue + _c=$(awk -F'\t' -v e="$_e" '$1 == e { n++ } END { printf "%d", n + 0 }' "$FIRED" 2>/dev/null) + _tt=$(seen_get "$_e" | awk '{ print $1 }') + _w=$(awk -F'\t' -v e="$_e" '$1 == e { print $2; exit }' "$FIRED" 2>/dev/null) + printf '| `%s` | %s | %s | %s |\n' "$_e" "$_c" "$_tt" "$_w" + done + else + printf '本輪沒有新事件。\n' + fi + + printf '\n#### 逐筆判定\n\n' + if [ -s "$ROWS" ]; then + printf '| id | 判定 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n' + printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n' + # 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會 + # 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄 + # 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。 + awk -F'\t' ' + function d(s) { return s == "" ? "-" : s } + function esc(s) { gsub(/\|/, "\\|", s); return s } + NF >= 11 && $1 != "" { + printf "| `%s` | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \ + $1, $2, esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10)) + }' "$ROWS" + else + printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n' + fi + + if [ "$T_UNWIRED" -gt 0 ] || [ "$T_INVALID" -gt 0 ]; then + printf '\n#### 判不了的那幾筆\n\n' + printf '上表 `unwired` 與 `invalid` 那幾筆**現在判不出到期,不是還沒發生**。不寫這一段的話,它們在頁面上跟一筆正常等著的待辦長得一模一樣。\n' + printf -- '- `analyze-completed:{HASH}` 的狀態來源在 wiki 的分析頁上,要連網才判得出來,還沒接線。\n' + printf -- '- `cron:{式子}` 還沒接線,重複的項目請改用 `every:{間隔}`。\n' + printf -- '- 欄位不合法的那幾筆要重新登錄一筆:`id` 由建立時間與標題算出來,改欄位值算不回同一個 id。\n' + fi + } >"$MD" + return 0 +} + +cmd_scan() { + _dry=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --out) [ "$#" -ge 2 ] || usage; OUT="$2"; shift 2 ;; + --now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;; + --dry-run) _dry=1; shift ;; + *) usage ;; + esac + done + resolve_now + [ -n "$OUT" ] || OUT="$STATE_DIR/due" + mkdir -p "$OUT" 2>/dev/null || die 5 "建不出輸出目錄 $OUT。" + ROWS="$OUT/rows.txt" + MD="$OUT/due.md" + + if [ "$_dry" -eq 1 ]; then + detect 0 + else + detect 1 + fi + scan_tasks + compose_md + + printf 'now=%s\n' "$(epoch_to_iso "$NOW")" + if [ "$_dry" -eq 1 ]; then + printf 'snapshot=preview\n' + elif [ "$RC_SNAP" -ne 0 ]; then + printf 'snapshot=failed\n' + elif [ "$FIRST_RUN" -eq 1 ]; then + printf 'snapshot=created\n' + else + printf 'snapshot=advanced\n' + fi + print_events + # stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這 + # 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。 + if [ -s "$ROWS" ]; then + awk -F'\t' 'NF >= 11 && $1 != "" { + printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s why=%s title=%s\n", \ + $1, $2, $3, $4, $5, $8, $9, $6, $7, $10, $11 + }' "$ROWS" + fi + printf 'tasks_total=%s\n' "$T_TOTAL" + printf 'tasks_due=%s\n' "$T_DUE" + printf 'tasks_wait=%s\n' "$T_WAIT" + printf 'tasks_invalid=%s\n' "$T_INVALID" + printf 'tasks_unwired=%s\n' "$T_UNWIRED" + printf 'tasks_skipped=%s\n' "$T_SKIP" + printf 'rows_file=%s\n' "$ROWS" + printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title\n' + printf 'due_file=%s\n' "$MD" + printf 'prev_file=%s\n' "$PREV" + printf 'seen_file=%s\n' "$SEEN" + cleanup_tmp + finish_rc +} + +cmd_events() { + while [ "$#" -gt 0 ]; do + case "$1" in + --now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;; + *) usage ;; + esac + done + resolve_now + # 一律唯讀。events 也推進快照的話,同一輪先叫 events 再叫 scan 就會把那一批事件吃掉,而 + # scan 那一次會回報零事件——看起來完全正常,實際上那一批事件永遠不會觸發任何一筆待辦。 + detect 0 + printf 'now=%s\n' "$(epoch_to_iso "$NOW")" + printf 'snapshot=preview\n' + print_events + note '這是唯讀預覽:快照與事件計數一個都沒動,所以同一批事件下一次 scan 還會再判出來一次。' + cleanup_tmp + finish_rc +} + +cmd_next() { + _tg=''; _rc=''; _cr=''; _lr=''; _fc=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --trigger) [ "$#" -ge 2 ] || usage; _tg="$2"; shift 2 ;; + --recur) [ "$#" -ge 2 ] || usage; _rc="$2"; shift 2 ;; + --created) [ "$#" -ge 2 ] || usage; _cr="$2"; shift 2 ;; + --last-run) [ "$#" -ge 2 ] || usage; _lr="$2"; shift 2 ;; + --fail-count) [ "$#" -ge 2 ] || usage; _fc="$2"; shift 2 ;; + --now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;; + *) usage ;; + esac + done + [ -n "$_tg" ] || usage + [ -n "$_rc" ] || usage + resolve_now + case "$_fc" in ''|*[!0-9]*) die 2 "--fail-count「$_fc」不是非負整數。" ;; esac + # created 沒給就退回當下:next 是純算,沒有待辦檔可讀。退回這件事要講一聲——at:now 與事件型 + # 的基準線都取 created,退回當下算出來的值跟真正那一筆算出來的不一樣。 + [ -n "$_cr" ] || { _cr=$(epoch_to_iso "$NOW"); note "沒給 --created,這一次退回當下($_cr)。at:now 的排定點與事件型的基準線都取 created,要算得跟待辦簿裡那一筆一樣就把它帶上。"; } + judge "$_tg" "$_rc" "$_cr" "$_lr" "$_fc" + printf 'now=%s\n' "$(epoch_to_iso "$NOW")" + printf 'verdict=%s\n' "$R_VERDICT" + printf 'next_run=%s\n' "$R_NEXT" + printf 'rearm_at=%s\n' "$R_REARM" + printf 'why=%s\n' "$R_WHY" + case "$R_VERDICT" in + invalid) exit 2 ;; + esac + exit 0 +} + +cleanup_tmp() { + rm -f "$STATE_DIR/.due.$$.next" "$STATE_DIR/.due.$$.fired" "$STATE_DIR/.due.$$.seen" 2>/dev/null + return 0 +} + +# 結束碼收斂。順序見檔頭:5、3、2、1、4。 +finish_rc() { + [ "$RC_FS" -ne 0 ] && exit "$RC_FS" + [ "$RC_SNAP" -ne 0 ] && exit "$RC_SNAP" + [ "$RC_FIELD" -ne 0 ] && exit "$RC_FIELD" + [ "$RC_SOURCE" -ne 0 ] && exit "$RC_SOURCE" + [ "$RC_EMPTY" -ne 0 ] && exit "$RC_EMPTY" + exit 0 +} + +resolve_now() { + if [ -n "$NOW_ARG" ]; then + case "$NOW_ARG" in ''|*[!0-9]*) die 2 "--now「$NOW_ARG」不是非負整數的 epoch 秒。" ;; esac + NOW="$NOW_ARG" + else + NOW=$(date -u +%s) + fi + return 0 +} + +# --- 主流程 --- + +NOW=0 +NOW_ARG='' +OUT='' +ROWS='' +MD='' +FIRST_RUN=0 +EV_NAMES='' +EV_NEW=0 +T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0 + +CMD="${1:-}" +[ -n "$CMD" ] || usage +shift +case "$CMD" in + scan) cmd_scan "$@" ;; + events) cmd_events "$@" ;; + next) cmd_next "$@" ;; + *) usage ;; +esac diff --git a/tools/patrol.sh b/tools/patrol.sh index 5f4d851..a789a88 100755 --- a/tools/patrol.sh +++ b/tools/patrol.sh @@ -61,6 +61,10 @@ # D-11 是唯一會動到別人狀態的一項:drain 會把事件流的位移往前推。理由與配套見下面 # 「執行狀態事件為什麼由這支排空」。 # +# 待辦簿那一節不在這五項裡面:它由 tools/due.sh 的 scan 組好,這支只叫它一次再把那一塊接進頁面。 +# 它也會動自己的狀態——推進待辦簿的事件快照,性質同 drain:同一輪叫第二次就比不出東西了。 +# 那一項的成敗不算進上面五項的成敗,理由寫在 due_scan() 那一段。 +# # --- version-guard.sh report 的既有缺陷照實記 --- # # 這支腳本在部分機器上對每一個 domain 都回「查詢失敗」,recommend 跟著回 unverifiable。 @@ -143,7 +147,11 @@ # failed_sources= 讀不到的來源路徑,以「、」分隔;全部讀得到就是「無」 # warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示 # 時,原因只寫在這裡 -# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,只供目錄頁那一個區塊用 +# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用 +# tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空 +# events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空 +# due_status= due_rc= 到期判定那一支的結果與結束碼 +# due_file= 到期判定那一支組好的「待辦簿到期與逾期」那一節,已經接進 latest_file # pending= 本輪待人處理的筆數 # events_total= 本輪排空到的事件筆數 # events_bad= 其中 status 不是 ok 的筆數 @@ -194,6 +202,12 @@ WARN_SOURCES='' OK_COUNT=0 FAIL_COUNT=0 WARN=0 +DUE_STATUS=fail +DUE_RC=0 +DUE_MD='' +DUE_NOTE='' +DUE_TASKS='' +DUE_EVENTS='' # 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。 # 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉; @@ -872,6 +886,65 @@ count_tasks() { return 0 } +# --- 待辦簿的事件偵測與到期判定 --- +# +# 算到期的邏輯不在這一支,也不在 tools/tasks.sh,而在 tools/due.sh,理由寫在那一支的檔頭: +# 存放與判定各一支,兩邊各算一次就會漂移。這裡只負責在一輪之內叫它一次。 +# 一輪只叫一次,而且只叫 scan:scan 會推進狀態快照,同一輪叫第二次就比不出任何事件了。 +# 要看「下一輪會判出什麼」而不想動快照,叫那一支的 events,那個子命令一律唯讀。 +# +# 這一項刻意不算進上面那五項的成敗。它補的是既有那一節的內容,不是新增一個獨立來源;算進去 +# 就要改動摘要表那一欄與「本輪項目」那一列的文字,而舊頁那些列的欄名不會跟著改,同一張表就 +# 會有兩種寫法。失敗時記一筆警示、列一筆待人處理,那一節照實寫「這一輪判不出到期」。 +due_scan() { + DUE_STATUS=fail; DUE_RC=0; DUE_MD=''; DUE_NOTE='' + DUE_TASKS=''; DUE_EVENTS='' + _due="$SCRIPT_DIR/due.sh" + if [ ! -f "$_due" ]; then + DUE_NOTE="找不到($_due)" + add_failed_source "$_due" + add_warn '到期判定那一支找不到' + add_pending '待辦簿的到期判定讀不到,jsc-assist 沒裝齊' '待辦簿到期與逾期' '/jsc-cli:deploy' + return 0 + fi + _rc=0 + JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" /dev/null | head -n1) + DUE_TASKS=$(sed -n 's/^tasks_due=//p' "$RD/due.out" 2>/dev/null | head -n1) + DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1) + case "$_rc" in + 0) DUE_STATUS=ok ;; + 4) + # 待辦簿零筆或目錄不存在。助理還沒收過待辦就是這個狀態,不是失敗。 + DUE_STATUS=empty ;; + 1) + DUE_STATUS=ok + DUE_NOTE='有狀態來源讀不到' + add_warn '到期判定有狀態來源讀不到' + add_pending '待辦簿事件偵測有狀態來源讀不到,那個來源本輪不發事件' '待辦簿到期與逾期' '/jsc-cli:setup' ;; + 2) + DUE_STATUS=ok + DUE_NOTE='有待辦的欄位值判不了' + add_warn '有待辦的欄位值判不了' + add_pending '有待辦的 trigger 或 recur 判不了,那幾筆永遠不會到期' '待辦簿到期與逾期' '/jsc-assist:assistant status' ;; + 3) + # 快照換不上去是這幾碼裡最要吵的一個:事件算出來了,但下一輪會拿同一份舊快照再比一次, + # 同一批事件於是被判第二次,同一件事就做了兩遍。 + DUE_STATUS=ok + DUE_NOTE='狀態快照換不上去,下一輪會重判同一批事件' + add_warn '事件快照換不上去' + add_pending '事件快照換不上去,同一批事件下一輪會被判第二次' '待辦簿到期與逾期' '/jsc-cli:setup' ;; + *) + DUE_NOTE="回結束碼 $_rc:$(tr '\n' ' ' <"$RD/due.err" 2>/dev/null)" + DUE_MD='' + add_failed_source "$_due" + add_warn '到期判定回非預期結束碼' + add_pending "待辦簿的到期判定回 $_rc,本輪判不出到期" '待辦簿到期與逾期' '/jsc-assist:assistant status' ;; + esac + return 0 +} + # --- 組出監控頁那三塊 --- tally() { # $1=項目狀態 @@ -915,9 +988,15 @@ compose() { printf '**這一輪不做這一項。** D-02 hook 錯誤巡檢還沒實作,這一節沒有資料不代表沒有 hook 錯誤。要現在查就跑 `/jsc-hooks:hooks-install` 的錯誤掃描,或直接跑 `jsc-hooks` 的 `tools/scan-hook-errors.sh`。\n\n' cat "$RD/d04.md"; printf '\n' cat "$RD/d07.md"; printf '\n' - printf '### 待辦簿到期與逾期\n\n' - printf '**這一輪只數筆數,不逐筆判到期。** 本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逐筆的到期與逾期判定還沒實作,要看逐筆內容就跑 `/jsc-assist:assistant status`。\n\n' \ - "$TASKS_TOTAL" "$TASKS_FAILING" + if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then + cat "$DUE_MD" + printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逾期(`due` 欄已經過了)還沒實作,上面的判定只講到期,不講逾期。\n\n' \ + "$TASKS_TOTAL" "$TASKS_FAILING" + else + printf '### 待辦簿到期與逾期\n\n' + printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \ + "$(cell "${DUE_NOTE:-沒有回報原因}")" "$TASKS_TOTAL" "$TASKS_FAILING" + fi printf '### 待人處理\n\n' printf '助理只提醒,不代為執行。這一節列的是本輪要人接手的項目。\n\n' printf '| 項目 | 來源子節 | 建議入口 |\n' @@ -1040,6 +1119,7 @@ case "$CMD" in d07 d11 count_tasks + due_scan tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS" tally "$D11_STATUS" @@ -1069,6 +1149,11 @@ case "$CMD" in printf 'warn_sources=%s\n' "${WARN_SOURCES:-無}" printf 'tasks_total=%s\n' "$TASKS_TOTAL" printf 'tasks_failing=%s\n' "$TASKS_FAILING" + printf 'tasks_due=%s\n' "$DUE_TASKS" + printf 'events_new=%s\n' "$DUE_EVENTS" + printf 'due_status=%s\n' "$DUE_STATUS" + printf 'due_rc=%s\n' "$DUE_RC" + printf 'due_file=%s\n' "$DUE_MD" printf 'pending=%s\n' "$PEND_COUNT" printf 'events_total=%s\n' "$EV_TOTAL" printf 'events_bad=%s\n' "$EV_BAD" From 1d220bf03f27736e769b5d45e311ded0fe257800 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 16:37:44 +0800 Subject: [PATCH 4/4] =?UTF-8?q?chore(manifest):=20=E4=B8=89=E4=BB=BD=20man?= =?UTF-8?q?ifest=20=E5=8D=87=E7=89=88=E8=87=B3=200.1.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事件偵測與到期判定要靠版號才傳得到機器端。 三份 manifest 由 sync-skill-manifest.sh 同步,只動版本欄位。 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ee7f699..4645ee5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.7", + "version": "0.1.8", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ab895cd..74ba713 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.7", + "version": "0.1.8", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/plugin.json b/plugin.json index f73a8e9..8c1b7d1 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.7", + "version": "0.1.8", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": {