From fb8b9baedd6abf53637fe26a8beb59fbb55b8199 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 10:08:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(assistant):=20=E5=B7=A1=E6=AA=A2?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=AD=97=E9=9D=A2=E7=B5=95=E5=B0=8D=E8=B7=AF?= =?UTF-8?q?=E5=BE=91=EF=BC=8C=E6=A0=B9=E7=9B=AE=E9=8C=84=E7=94=B1=E6=8E=92?= =?UTF-8?q?=E7=A8=8B=E6=A2=9D=E7=9B=AE=E5=B8=B6=E9=80=B2=E4=BE=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 無人看管的排程輪次在第一支腳本就被權限層擋下。心跳因此寫不出來,助理斷了兩個多小時,沒有人發現。 2026-09-02 到 09-03 用非互動模式比照排程環境實測七種寫法,歸納出兩條判準。一、無人值守時只有允許清單上的完整字面指令跑得動,沒有「預設安全的唯讀指令」這回事,連 readlink 與 ls 都要有自己的規則。二、路徑中段的萬用字元不匹配,規則與指令都必須是完整字面,所以帶版本號的快取路徑放不進允許清單。「先解路徑再用」因此不成立:解路徑的指令自己就過不了,而路徑能寫成字面就不必解。 助理技能新增路徑守則與 Step 0。守則寫明權限層比對的是指令還沒展開的字面字串,帶未展開變數或波浪號的路徑一律要核准,並附上七列實測佐證表。Step 0 從「自己跑 readlink 解路徑」改成「從叫用文字的『工具根目錄=』取字面絕對路徑」,排程那一輪一個解析指令都不跑;人在現場叫用才用 readlink 解一次,那一次有人可以按同意。取不到根目錄就停下回報,收尾狀態取 aborted,不猜也不退回帶變數的路徑。全篇 46 處腳本呼叫改成字面絕對路徑。 排程工具在安裝時把解好的字面根目錄寫進條目的提示文字,並印成 patrol_root=。條目與允許規則共用同一個值,兩邊各算各的就會差開,而差開的那一輪是被靜靜擋掉。允許規則的提示改成完整字面路徑,不寫變數、波浪號與萬用字元。JSC_HOME 解不出絕對路徑時回結束碼 6,不讓相對路徑寫進條目。自訂巡檢指令沒帶那一段只警告、不中止。 行為契約四列與說明文件兩處敘述一併跟上。 --- README.md | 4 +- references/behaviors.md | 8 +-- skills/assistant/SKILL.md | 129 +++++++++++++++++++++++++------------- tools/schedule.sh | 93 +++++++++++++++++++-------- 4 files changed, 160 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index e098f82..9d516b3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 ### `assistant` -助理主體,四個操作:`start` 啟動、`status` 查現況、`patrol` 跑一輪巡檢、`stop` 停止。心跳的寫入、判定與清除一律交給 `jsc-hooks` 的 `hooks/heartbeat.sh`,判定只有那一份;系統排程一律交給 `tools/schedule.sh`;一輪巡檢的流程交給 `tools/patrol.sh`。工具一律用 `$JSC_HOME/current/{外掛名}` 那一組不帶版本的路徑叫,不用技能提示給的快取基底目錄——權限只放行 current 那一組。**心跳由巡檢寫,而且只由巡檢寫**:一輪跑完、結果寫上監控頁了,才寫那一次心跳,所以心跳新鮮等於「上一輪巡檢真的做完了」。`start` 先跑一輪巡檢,再裝上巡檢那一筆排程;巡檢週期由心跳的過期門檻算出來,兩個數字綁在一起。`patrol` 讀五項來源(使用統計、版本與重啟閘門、SDLC 階段鎖與工作包鎖、心跳自述、執行狀態事件),各項各自獨立,一項掛掉其餘各項照跑、照記,結果寫上 `MONITOR_{HASH}`:那頁固定三塊,基本資料不動、最新一輪整塊換掉、摘要表保留近 24 輪,一輪一列。目錄頁 `MONITOR_CONTENTS` 在另一個存取庫(`JSC_WIKI_REPO_CONTENTS`),一台機器一個 H2 區塊,只更新自己那一個區塊,交給 `jsc-gitea/tools/wiki-contents.sh upsert` 寫,連結用絕對網址;那個存取庫沒設定時只少一筆索引,這一輪照樣算跑完、照樣寫心跳。`status` 全程唯讀,讀心跳、排程與待辦簿,印成三塊;助理沒在跑就印「助理未運行」,不當成錯誤。`stop` 先移除排程再清掉心跳,順序不能反。這支不參與閘門判定、不做決策、巡檢那一路全程不問人。 +助理主體,四個操作:`start` 啟動、`status` 查現況、`patrol` 跑一輪巡檢、`stop` 停止。心跳的寫入、判定與清除一律交給 `jsc-hooks` 的 `hooks/heartbeat.sh`,判定只有那一份;系統排程一律交給 `tools/schedule.sh`;一輪巡檢的流程交給 `tools/patrol.sh`。工具一律用 current 那一組不帶版本的字面絕對路徑叫,不用技能提示給的快取基底目錄。根目錄由叫用文字的 `工具根目錄=` 帶進來,那一輪自己不解——權限比對指令的字面字串,帶未展開變數或波浪號的路徑一律要核准,解路徑的指令本身在無人值守時同樣被擋。規則與指令都必須是完整字面,路徑中段寫萬用字元不匹配,所以快取那組帶版本號的路徑放不進允許清單。**心跳由巡檢寫,而且只由巡檢寫**:一輪跑完、結果寫上監控頁了,才寫那一次心跳,所以心跳新鮮等於「上一輪巡檢真的做完了」。`start` 先跑一輪巡檢,再裝上巡檢那一筆排程;巡檢週期由心跳的過期門檻算出來,兩個數字綁在一起。`patrol` 讀五項來源(使用統計、版本與重啟閘門、SDLC 階段鎖與工作包鎖、心跳自述、執行狀態事件),各項各自獨立,一項掛掉其餘各項照跑、照記,結果寫上 `MONITOR_{HASH}`:那頁固定三塊,基本資料不動、最新一輪整塊換掉、摘要表保留近 24 輪,一輪一列。目錄頁 `MONITOR_CONTENTS` 在另一個存取庫(`JSC_WIKI_REPO_CONTENTS`),一台機器一個 H2 區塊,只更新自己那一個區塊,交給 `jsc-gitea/tools/wiki-contents.sh upsert` 寫,連結用絕對網址;那個存取庫沒設定時只少一筆索引,這一輪照樣算跑完、照樣寫心跳。`status` 全程唯讀,讀心跳、排程與待辦簿,印成三塊;助理沒在跑就印「助理未運行」,不當成錯誤。`stop` 先移除排程再清掉心跳,順序不能反。這支不參與閘門判定、不做決策、巡檢那一路全程不問人。 @@ -43,7 +43,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | 檔案 | 用途 | | --- | --- | -| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`。`JSC_WIKI_REPO` 系列含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`:監控頁 `MONITOR_{HASH}` 與目錄頁 `MONITOR_CONTENTS` 分屬不同存取庫,兩支變數都要帶。名單是安裝當下從環境撈出所有已設定的,不寫死,所以新增的頁型變數自動涵蓋,這支不必跟著改——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動 | +| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`。`JSC_WIKI_REPO` 系列含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`:監控頁 `MONITOR_{HASH}` 與目錄頁 `MONITOR_CONTENTS` 分屬不同存取庫,兩支變數都要帶。名單是安裝當下從環境撈出所有已設定的,不寫死,所以新增的頁型變數自動涵蓋,這支不必跟著改——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。安裝當下把解好的字面根目錄寫進條目的提示文字(`工具根目錄={絕對路徑}`)並印成 `patrol_root=`:那一輪自己解不出根目錄,只能從提示文字拿,拿不到就停下回報;自訂巡檢指令沒帶這一段只警告、不中止。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動 | | `tools/patrol.sh` | 一輪巡檢的收攏與收口。三個子命令:`collect` 取鎖、讀五項來源、組出監控頁的「最新一輪」與「近 24 輪摘要」兩塊、本輪的摘要列與目錄頁那一個區塊(區塊的 H2 標題是內容頁頁名 `MONITOR_{HASH}`,upsert 拿標題當鍵;「監控頁」那一條是連結,網址留佔位,等監控頁寫成之後由呼叫端用 `gitea.sh wiki-url` 的絕對網址換掉);`finish` 在監控頁寫成之後才寫心跳、換上用量快照、放掉鎖;`abort` 只放掉鎖,不寫心跳。各項來源各自獨立,一項失敗其餘各項照跑,失敗那一項在頁上寫明是「這一項失敗」而不是沒資料。執行狀態事件那一項由 `collect` 自己叫 `jsc-hooks/tools/report-status.sh` 排空再輪替,把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成頁上那一節;`drain` 是消耗性讀取,所以只由這支跑,且它失敗一律不中止那一輪。整輪拿一把目錄鎖,上一輪還在跑就回 4 讓開;鎖逾時(門檻取心跳門檻)會被下一輪搶回來,並在頁上記一筆。`version-guard.sh report` 回「查詢失敗」時照原字抄,不補查、不美化 | | `references/behaviors.md` | 本 domain 的技能行為清單:一支技能一節,五列記下觸發時機、關鍵步驟、外部呼叫、完成條件、可驗證跡象,供稽核與驗證比對。格式合約見 `plugins/meta` 的 `references/guidelines.md`「技能行為清單」 | | `templates/monitor-contents.md` | 目錄頁 `MONITOR_CONTENTS` 的範本,這一頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的專用存取庫,和監控頁不同庫。版面是 H1、`>` 引言,然後一台機器一個 H2 區塊,欄位在標題底下一行一條 `- {欄位名}:{值}`,頁上不放 markdown 表格。H2 標題就是內容頁頁名 `MONITOR_{HASH}`,雜湊來源是 `{主機名}/{登入帳號}`,主機名取短的那一段。寫入一律走 `jsc-gitea/tools/wiki-contents.sh upsert`,比對鍵是 H2 標題:**只更新自己那一個區塊**,別台機器的區塊原樣保留,禁止整頁覆蓋。「監控頁」那一條的連結一律寫成 `[{頁名}]({絕對網址})`,網址取 `gitea.sh wiki-url` 印的那一個,寫入前先過 `jsc-gitea/tools/link-check.sh`、結束碼 0 才寫;但那一條含主機位址與網址編碼,會變,所以不當鍵 | diff --git a/references/behaviors.md b/references/behaviors.md index d2ddadd..bb2d53c 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -7,7 +7,7 @@ | 項目 | 內容 | | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。執行環境健檢不走這支,走 `jsc-cli:doctor`。技能使用次數不走這支,走 `jsc-log:stats` | -| 關鍵步驟 | 先認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀五項來源(第五項是執行狀態事件:`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` 由這裡寫,不寫就等於這一次自己看起來中止了 | -| 外部呼叫 | 工具一律走 `$JSC_HOME/current/{外掛名}` 那一組不帶版本的路徑:`current/jsc-assist/tools/patrol.sh`、`current/jsc-assist/tools/schedule.sh`、`current/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `current/jsc-gitea/tools/gitea.sh`,連結驗證那一支是 `current/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `current/jsc-hooks/tools/report-status.sh`,`$JSC_HOME` 沒設就退回 `~/.jsc`;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會查 `$JSC_HOME/current/jsc-assist` 與 `$JSC_HOME/current/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、並印出這一輪要開的 `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` 上。本 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` 那一路一律不問。不參與閘門判定 | -| 完成條件 | `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 只回報成回報鏈的缺陷,不改寫這一次操作的成敗 | -| 可驗證跡象 | `start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照;install 印出的 `allow_rule=` 都是 `$JSC_HOME/current` 那一組確切路徑,沒有萬用字元,也沒有 `Write(...)`,而且 `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`,摘要列是五欄、警示來源那一欄有值或寫「無」;兩支腳本不是從 `$JSC_HOME/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`。除了人在現場那一次 `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` 那一路一律不問。不參與閘門判定 | +| 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解不出來就回報缺 `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/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index 81a48ea..f547b18 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -7,33 +7,77 @@ description: 'Start, inspect, patrol or stop the background assistant: jsc-hooks The background assistant runs where nobody is watching it. Its heartbeat is the only evidence that it is alive, so this skill is the single entry point for the four operations that touch that evidence: `patrol` writes it, `status` reads it, `stop` clears it, and `start` bootstraps the whole loop. -`$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh` owns every heartbeat operation, including the freshness verdict. Never read, parse, write or delete `$JSC_HOME/assistant/heartbeat` directly — one verdict, one source. +`{CURRENT}/jsc-hooks/hooks/heartbeat.sh` owns every heartbeat operation, including the freshness verdict. Never read, parse, write or delete `$JSC_HOME/assistant/heartbeat` directly — one verdict, one source. -`$JSC_HOME/current/jsc-assist/tools/schedule.sh` owns every system-scheduler operation: installing an entry, removing it, and reading which entries exist. Never call `crontab` or `schtasks` from this skill, and never edit a crontab by hand. +`{CURRENT}/jsc-assist/tools/schedule.sh` owns every system-scheduler operation: installing an entry, removing it, and reading which entries exist. Never call `crontab` or `schtasks` from this skill, and never edit a crontab by hand. -`$JSC_HOME/current/jsc-assist/tools/patrol.sh` owns one patrol round: taking the round lock, reading the five sources, composing the monitor page's blocks, and — after the page carries this round — writing the heartbeat. Never re-read a source this skill already handed to that script, and never compose a block by hand; the script prints the file paths. +`{CURRENT}/jsc-assist/tools/patrol.sh` owns one patrol round: taking the round lock, reading the five sources, composing the monitor page's blocks, and — after the page carries this round — writing the heartbeat. Never re-read a source this skill already handed to that script, and never compose a block by hand; the script prints the file paths. All three flows have fixed inputs and outputs, so all three live in scripts. The task book is the only thing this skill reads for itself, and that is one directory listing. +## Step 0 — take the tool root from the invocation + +Every operation starts here, before its own step 1. **This document calls the tool root `{CURRENT}`**, and every `{CURRENT}` below is replaced by it character for character: `{CURRENT}/jsc-assist/tools/patrol.sh` is run as `/root/.jsc/current/jsc-assist/tools/patrol.sh`. + +The root comes from outside this skill. `schedule.sh` resolves it while a person is installing the schedule, and writes it into the entry's prompt as `工具根目錄={literal absolute path}`, so the round that entry wakes reads the root out of the text that woke it and runs no command at all. + +| Who invoked this round | Where `{CURRENT}` comes from | +| --- | --- | +| the schedule — an unattended round, the `patrol` whose trigger is 排程 | the path after `工具根目錄=` in the invocation text, taken verbatim. No command is run | +| a person, in front of the terminal | the same token when the invocation carries one; otherwise `readlink -f "$JSC_HOME/current"`, run once | + +**An unattended round that finds no root in its invocation stops there.** Report that the scheduled entry carries no `工具根目錄=` — an entry written by an older `schedule.sh` — say the fix is to run `start` again, or `jsc-assist/tools/schedule.sh install patrol` under `$JSC_HOME/current`, so the entry is rewritten with the root in it. Then take the operation's `aborted` status, write the `skill-end`, and stop. + +Never work the root out instead. `readlink -f "$JSC_HOME/current"`, `ls -d "$JSC_HOME/current"` and every other resolve are refused in an unattended session — measured, see the table below — so running one does not produce a root, it produces a round that stops one step earlier having recorded nothing. Never fall back to `$JSC_HOME/current` as a written-out path either, never take a path from the plugin prompt or a previous transcript, and never guess. + +**Only an attended invocation may resolve the root itself.** `readlink -f "$JSC_HOME/current"` covers the documented `~/.jsc` fallback in the same call and prints one literal absolute path. It raises one permission prompt, and a person is there to answer it once. That is the whole reason the branch exists: portability survives where somebody can approve it, and nowhere else. + +Take the root once per invocation and reuse that one answer. Never resolve it again per call, never print it as a report line of its own, and never add a tool that prints it. Never test the root with a command either — an unattended round cannot, and the first script call is the test that matters anyway. + +An empty token, an empty `readlink` result, or a path that is not absolute means there is no root to work with. Report it, say `jsc-cli:deploy` has to run, take the operation's `aborted` status, and stop. Never fall back to a cache path, and never create the root here. Completion condition: one literal absolute path is in hand and every later command line carries it, or the missing root was reported and the operation stopped. + +## Every script call carries a literal absolute path + +**No command line in this skill carries a variable or a tilde, and the one resolve above is the single exception, allowed only when a person is watching.** Never type `$JSC_HOME`, `${JSC_HOME}` or `~` into any other command line. + +The reason is the permission layer: it matches its rules against the command text as written, before the shell expands anything. Two properties follow from what was measured on this machine, and every rule in this skill rests on them: + +1. **Unattended, only a full literal command that is on the allow list runs.** There is no such thing as a read-only command that is safe by default: a bare `ls -d` is refused exactly like everything else, and a refusal in a session with nobody in it is silent. +2. **A wildcard in the middle of a path does not match.** The rule and the command both have to be complete literals. A rule holding `*` where a version number goes matches nothing, so a cache path is refused however the rule is written. + +| Command | Result | +| --- | --- | +| `/root/.jsc/current/jsc-assist/tools/patrol.sh` — a literal rule for it is on the allow list | ran | +| `$JSC_HOME/current/jsc-assist/tools/patrol.sh` | refused | +| `~/.jsc/current/jsc-assist/tools/patrol.sh` | refused | +| `readlink -f "$JSC_HOME/current"` | refused | +| `ls -d "$JSC_HOME/current"` | refused | +| `ls -d /root/.jsc/current` — literal, read-only, no rule for it | refused | +| `/root/.claude/plugins/cache/jsc/jsc-assist/0.1.0/tools/patrol.sh` — rule written with `*` for the version segment | refused | + +A literal allow rule that itself starts with `$JSC_HOME` was added to the settings file and the same call was still refused, so no permission rule makes the variable form work either. The literal path is the whole fix, on both sides. + +**These rules outrank portability, and the next maintainer is the one who has to know why.** A variable in the path reads as the portable choice and costs nothing while a person is watching: the prompt appears, somebody approves it, the round carries on. The scheduled round has nobody to approve it. It stops at its first script call, records nothing, writes no heartbeat, and the machine then reads as a stopped assistant with no trace of the refusal anywhere. And the resolve is no way out of that, because row 4 of the table is the resolve itself: a round that cannot run a script cannot run the command that would have told it which script to run. That is why the root is handed in by whoever installed the schedule, and why anything written into a command line here is already literal. + ## Tool paths -Every tool below is addressed through `$JSC_HOME/current/{plugin}`, and `$JSC_HOME` falls back to `~/.jsc` exactly as everywhere else in this skill: +Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` standing for the literal path step 0 took: | What it does | Path to run | | --- | --- | -| one patrol round | `$JSC_HOME/current/jsc-assist/tools/patrol.sh` | -| the system scheduler | `$JSC_HOME/current/jsc-assist/tools/schedule.sh` | -| the heartbeat | `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh` | -| the status event stream | `$JSC_HOME/current/jsc-hooks/tools/report-status.sh` | -| the wiki, through `jsc-gitea:wiki` | `$JSC_HOME/current/jsc-gitea/tools/gitea.sh` | -| the `MONITOR_CONTENTS` entry | `$JSC_HOME/current/jsc-gitea/tools/wiki-contents.sh` | -| the link check every write depends on | `$JSC_HOME/current/jsc-gitea/tools/link-check.sh` | +| one patrol round | `{CURRENT}/jsc-assist/tools/patrol.sh` | +| the system scheduler | `{CURRENT}/jsc-assist/tools/schedule.sh` | +| the heartbeat | `{CURRENT}/jsc-hooks/hooks/heartbeat.sh` | +| the status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` | +| the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` | +| the `MONITOR_CONTENTS` entry | `{CURRENT}/jsc-gitea/tools/wiki-contents.sh` | +| the link check every write depends on | `{CURRENT}/jsc-gitea/tools/link-check.sh` | **A `Skill(...)` rule permits invoking that skill and nothing more.** Every Bash call inside it is still checked on its own, so `jsc-gitea:wiki` reaching the wiki depends on `gitea.sh` carrying its own rule, the directory entry depends on `wiki-contents.sh` carrying one too, and both writes depend on `link-check.sh` carrying one — without them the round is refused locally, before any request leaves the machine, and the page never gets written. **Never build a tool path out of the base directory the CLI hands you in the skill prompt.** That directory points into the plugin cache and carries a version segment, and the permission gate allows exactly the seven paths above and nothing else. A cache path is therefore refused silently: the round stops on a permission prompt nobody can answer, records nothing, writes no heartbeat, and the refusal looks exactly like a broken tool. Read the paths off this table every time — not off the prompt, not off a previous transcript, not off `crontab -l`. -Both scripts check this for themselves: run from anywhere outside `$JSC_HOME/current`, they print a `[WARN]` line on stderr naming the path they were started from and the path they should have been started from, and then carry on. That line means this round is on the wrong path — quote it, fix the path, and do not treat the round's success as proof that the path was fine. +Both scripts check this for themselves: run from anywhere outside `{CURRENT}`, they print a `[WARN]` line on stderr naming the path they were started from and the path they should have been started from, and then carry on. That line means this round is on the wrong path — quote it, fix the path, and do not treat the round's success as proof that the path was fine. `current` is a set of version-free links that `jsc-cli:deploy` maintains, so an upgrade moves the cache and leaves these paths alone. When one of them is missing, report the missing link and say `jsc-cli:deploy` has to run; never fall back to a cache path to get the round through, and never create the link here. @@ -41,9 +85,9 @@ Both scripts check this for themselves: run from anywhere outside `$JSC_HOME/cur Both pages this round writes carry links, and both rules below hold for every one of them — the monitor page and the directory entry alike. -**Rule A — a link is always written as `[{text}]({URL})`.** The wiki's own `[[page]]` and `[[text|page]]` forms are not used here at all, and neither is the split between "same repo" and "cross repo" writing. The URL comes from `$JSC_HOME/current/jsc-gitea/tools/gitea.sh wiki-url {repo} {page}`; never assemble a path by hand. `[[...]]` resolves only inside the wiki it sits in: the monitor page and the directory page live in two different repos, so a `[[MONITOR_{HASH}]]` written into the directory entry renders as an ordinary-looking link that goes nowhere, and nothing reports it. +**Rule A — a link is always written as `[{text}]({URL})`.** The wiki's own `[[page]]` and `[[text|page]]` forms are not used here at all, and neither is the split between "same repo" and "cross repo" writing. The URL comes from `{CURRENT}/jsc-gitea/tools/gitea.sh wiki-url {repo} {page}`; never assemble a path by hand. `[[...]]` resolves only inside the wiki it sits in: the monitor page and the directory page live in two different repos, so a `[[MONITOR_{HASH}]]` written into the directory entry renders as an ordinary-looking link that goes nowhere, and nothing reports it. -**Rule B — a link is verified before it is written, never after.** Collect every link that is about to go into the page, hand the whole set to `$JSC_HOME/current/jsc-gitea/tools/link-check.sh`, and write only on exit 0. The script prints one `{OK|DEAD|SKIP}{URL}{note}` line per URL and checks Gitea URLs through the API, never through the web status code — a private repo answers 404 to a logged-out web request, so a status-code check condemns live pages. +**Rule B — a link is verified before it is written, never after.** Collect every link that is about to go into the page, hand the whole set to `{CURRENT}/jsc-gitea/tools/link-check.sh`, and write only on exit 0. The script prints one `{OK|DEAD|SKIP}{URL}{note}` line per URL and checks Gitea URLs through the API, never through the web status code — a private repo answers 404 to a logged-out web request, so a status-code check condemns live pages. | Exit | Meaning | Do | | --- | --- | --- | @@ -63,7 +107,7 @@ Run exactly one operation per invocation. Take it from the request: starting, la The last thing any of the four operations does, after its report is printed, is write its own end event: -`$JSC_HOME/current/jsc-hooks/tools/report-status.sh skill-end jsc-assist:assistant {status} {exit} "{one line}"` +`{CURRENT}/jsc-hooks/tools/report-status.sh skill-end jsc-assist:assistant {status} {exit} "{one line}"` The `start` half is already on record — a hook writes it when this skill loads — so this call is what tells the difference between an operation that finished and one that stopped half way. **Skipping it makes this skill's own run look aborted**, and the next patrol round reports it as such, on the page this skill writes. Pick the status from what actually happened: @@ -110,7 +154,7 @@ Every call in every operation below is judged by this table. Report the code you ## The scheduler -Nothing in a background assistant runs on its own. The system scheduler is what makes it periodic, and `$JSC_HOME/current/jsc-assist/tools/schedule.sh` is the only thing here that touches it. One job exists, written as exactly one entry carrying the fixed marker `# jsc-assist:assistant patrol`: +Nothing in a background assistant runs on its own. The system scheduler is what makes it periodic, and `{CURRENT}/jsc-assist/tools/schedule.sh` is the only thing here that touches it. One job exists, written as exactly one entry carrying the fixed marker `# jsc-assist:assistant patrol`: | Job | Period | Runs | Installed by `start` | | --- | --- | --- | --- | @@ -130,11 +174,12 @@ Four properties of that script matter enough to state here, because a report tha - **The log lives at `$JSC_HOME/assistant/schedule.log`**, deliberately outside every repository. Do not offer to move it into a project. - **The entry runs with no human present.** The command is installed with `` preamble, and then one H2 block per machine — the heading is that machine's monitor page name, and the fields are one `- {name}:{value}` bullet each underneath. The script owns the read-match-write of one block, so never read this page and rebuild it by hand, never write it through `jsc-gitea:wiki`, and never rebuild it the way step 3 rebuilds the content page — every other block here belongs to a machine that is not this one, and one careless whole-page write deletes their records. - **Finish the block first.** `contents_file` holds this machine's whole block — `## MONITOR_{HASH}`, a blank line, then the bullets — and its 監控頁 bullet already carries the rule A shape `[{page name}]({URL})` with the placeholder `{監控頁絕對網址}` standing in for the URL, because the absolute URL cannot be known until step 3 has actually put the page. Run `$JSC_HOME/current/jsc-gitea/tools/gitea.sh wiki-url {the MONITOR repo step 3 resolved} MONITOR_{HASH}`, replace the placeholder with what it prints, and write the finished block to a file. Exit 4 there means step 3's write has not landed — go back to step 3 rather than writing a block. Exit 5 means the page carries no `html_url`: report it and never assemble a URL by hand. Exit 7 or 8: report the code and take the abort row below. **Any other non-zero exit takes the same abort row**, a missing argument included — a URL that never arrived would otherwise leave that bullet holding the raw placeholder, and the block would still be written. + **Finish the block first.** `contents_file` holds this machine's whole block — `## MONITOR_{HASH}`, a blank line, then the bullets — and its 監控頁 bullet already carries the rule A shape `[{page name}]({URL})` with the placeholder `{監控頁絕對網址}` standing in for the URL, because the absolute URL cannot be known until step 3 has actually put the page. Run `{CURRENT}/jsc-gitea/tools/gitea.sh wiki-url {the MONITOR repo step 3 resolved} MONITOR_{HASH}`, replace the placeholder with what it prints, and write the finished block to a file. Exit 4 there means step 3's write has not landed — go back to step 3 rather than writing a block. Exit 5 means the page carries no `html_url`: report it and never assemble a URL by hand. Exit 7 or 8: report the code and take the abort row below. **Any other non-zero exit takes the same abort row**, a missing argument included — a URL that never arrived would otherwise leave that bullet holding the raw placeholder, and the block would still be written. - **Then verify that URL before the block goes anywhere.** Run `$JSC_HOME/current/jsc-gitea/tools/link-check.sh {the URL just substituted}` and read the exit code by the rule B table above. Exit 0 is the only result that permits the upsert. On exit 1 the directory would gain a block pointing at a page that is not there: report the `DEAD` line verbatim, write no block, and treat the directory entry as not updated — the round's own result is already on `MONITOR_{HASH}`, so carry on to step 5 and write the heartbeat, exactly as exit 3 from the upsert does, and put the dead link into the 待人處理 rows. Exits 2, 3 and 7 are reported the same way and the block is left unwritten. Never write the block first and check afterwards: the directory is what other people read to find this machine, and a dead link there sends every one of them to a page that does not exist. + **Then verify that URL before the block goes anywhere.** Run `{CURRENT}/jsc-gitea/tools/link-check.sh {the URL just substituted}` and read the exit code by the rule B table above. Exit 0 is the only result that permits the upsert. On exit 1 the directory would gain a block pointing at a page that is not there: report the `DEAD` line verbatim, write no block, and treat the directory entry as not updated — the round's own result is already on `MONITOR_{HASH}`, so carry on to step 5 and write the heartbeat, exactly as exit 3 from the upsert does, and put the dead link into the 待人處理 rows. Exits 2, 3 and 7 are reported the same way and the block is left unwritten. Never write the block first and check afterwards: the directory is what other people read to find this machine, and a dead link there sends every one of them to a page that does not exist. Then run, with the template as the fifth argument every time: - `$JSC_HOME/current/jsc-gitea/tools/wiki-contents.sh upsert MONITOR 1 "MONITOR_{HASH}" {block file} $JSC_HOME/current/jsc-assist/templates/monitor-contents.md` + `{CURRENT}/jsc-gitea/tools/wiki-contents.sh upsert MONITOR 1 "MONITOR_{HASH}" {block file} {CURRENT}/jsc-assist/templates/monitor-contents.md` **The key is the H2 heading — the page name `MONITOR_{HASH}`**, taken from `collect`'s `page=` line verbatim, with no link, no brackets and no URL around it. The script compares the heading text, so the 監控頁 bullet cannot be the key: it holds `GITEA_HOST` and the wiki's encoding of the page name, so a changed host, a `JSC_WIKI_REPO_MONITOR` pointed at another repo, or a different URL encoding changes that text and stops it matching. This page is written once every round, so from the moment matching breaks every round appends one more block for this same machine and the old block is never updated again. The page name depends on `{host}/{user}` alone, which none of those three touch. That bullet's link stays in the block for people to click, and never for matching. A key typed by hand matches nothing either, and appends the same duplicate block. @@ -260,7 +305,7 @@ One round: read five sources, record the result, then beat. Everything before th | Exit | Do | | --- | --- | | 0 | The block is in place. The script prints `updated` or `added` plus the page it wrote — carry that word into the report, and carry on to step 5 | - | 1 | The page content could not be built, or the write failed. Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. A page with no matching block is not this code: an unmatched key is an append | + | 1 | The page content could not be built, or the write failed. Run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. A page with no matching block is not this code: an unmatched key is an append | | 2 | An argument was rejected and nothing was written. A template path that does not exist lands here too, and means the plugin installation is incomplete. Correct the call and run it once more; report a second exit 2 as a defect in this skill, then abort and stop | | 3 | No `CONTENTS` wiki repo is configured. **This one does not stop the round.** Carry on to step 5 and write the heartbeat: the round's result is already on `MONITOR_{HASH}`, and that is exactly what a heartbeat stands for. Report the directory entry as not updated, name `JSC_WIKI_REPO_CONTENTS` and `JSC_WIKI_REPO` as the two variables to set, and add that to the 待人處理 rows. Never abort a recorded round over the directory page — a missing directory block loses one index entry, an aborted round loses the whole round, and the patrol cannot ask anybody for the missing setting | | 4 | The page is absent and no template reached the script. The call above always passes the template as its fifth argument, so this code cannot come out of it — getting it means that argument was dropped, so restore it and run the call once more. A template path that does not exist is rejected as exit 2, never as 4 | @@ -269,7 +314,7 @@ One round: read five sources, record the result, then beat. Everything before th Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran. -5. **Write the heartbeat.** Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code. +5. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code. 6. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL. @@ -281,7 +326,7 @@ One round: read five sources, record the result, then beat. Everything before th Read-only throughout. This operation creates, modifies and deletes nothing under `$JSC_HOME`, and it never calls `write` or `clear`. -1. **Read the heartbeat through the script.** Run `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh report` and split the line on spaces, taking `file=` last so a path containing spaces stays intact. Map `state=` to the verdict: `fresh` → `新鮮`, `stale` → `過期`, `invalid` → `心跳檔損壞`, `absent` → `不存在`. Print `助理未運行` for `stale`, `invalid` and `absent`. Never re-derive the verdict from `ts` yourself, and never treat `invalid` as fresh. On exit 2 or 6, follow that code's row, record the heartbeat state as unknown, and carry on to step 2 — the task book is still worth printing. Completion condition: the heartbeat state holds one of `新鮮`, `過期`, `心跳檔損壞`, `不存在` or unknown, and `ts`, `age`, `ttl`, `pid`, `cli`, `session` and `file` are recorded as read or as empty. +1. **Read the heartbeat through the script.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and split the line on spaces, taking `file=` last so a path containing spaces stays intact. Map `state=` to the verdict: `fresh` → `新鮮`, `stale` → `過期`, `invalid` → `心跳檔損壞`, `absent` → `不存在`. Print `助理未運行` for `stale`, `invalid` and `absent`. Never re-derive the verdict from `ts` yourself, and never treat `invalid` as fresh. On exit 2 or 6, follow that code's row, record the heartbeat state as unknown, and carry on to step 2 — the task book is still worth printing. Completion condition: the heartbeat state holds one of `新鮮`, `過期`, `心跳檔損壞`, `不存在` or unknown, and `ts`, `age`, `ttl`, `pid`, `cli`, `session` and `file` are recorded as read or as empty. 2. **Read the task book.** Take the assistant directory from the `file=` path of step 1, list the regular files directly under its `tasks/` subdirectory, and parse each one as `key=value` lines. Branch on the outcome. @@ -294,7 +339,7 @@ Read-only throughout. This operation creates, modifies and deletes nothing under Completion condition: every file under `tasks/` produced exactly one row, or zero entries was reported. -3. **Read the schedule.** Run `$JSC_HOME/current/jsc-assist/tools/schedule.sh status`. It writes nothing. Record `mechanism=`, `service=`, `ttl=`, `period=` and the `installed=` value of both jobs. A `heartbeat` job reported as installed is a leftover from an older version: say so, and say `start` or `schedule.sh install patrol` removes it. On exit 2, 3 or 6 nothing was read: record the schedule state as unknown with its code and carry on — the heartbeat and the task book still print. Completion condition: both jobs have an installed state, or the schedule state is recorded as unknown with its code. +3. **Read the schedule.** Run `{CURRENT}/jsc-assist/tools/schedule.sh status`. It writes nothing. Record `mechanism=`, `service=`, `ttl=`, `period=` and the `installed=` value of both jobs. A `heartbeat` job reported as installed is a leftover from an older version: say so, and say `start` or `schedule.sh install patrol` removes it. On exit 2, 3 or 6 nothing was read: record the schedule state as unknown with its code and carry on — the heartbeat and the task book still print. Completion condition: both jobs have an installed state, or the schedule state is recorded as unknown with its code. 4. **Print the status table.** Lead with the heartbeat block — verdict, last heartbeat time rendered from `ts` in local time, age in seconds, TTL, `cli`, `session`, `pid`, and the task count. Follow it with the schedule block — mechanism, service state, derived period, and one line per job saying installed or not. Then one row per task carrying `state`, `title`, `next_run` and `fail_count`, in the order the files were listed. Completion condition: the heartbeat block holds all eight values, the schedule block holds both jobs and the period, and the row count equals the task count from step 2. @@ -315,11 +360,11 @@ Read-only throughout. This operation creates, modifies and deletes nothing under ## stop -1. **Record what is being stopped.** Run `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh report` first and keep its `state=`, `ts=`, `pid=`, `cli=` and `file=` fields for the closing report — after the clear they are gone for good. `state=absent` means no round has finished; say so and still run steps 2 and 3, because a scheduled entry can outlive its heartbeat and `clear` on a missing file is a success, so running both leaves the outcome unambiguous. On exit 2 or 6, follow that code's row, record the previous state as unknown, and carry on to step 2. Completion condition: the previous state and its fields are recorded, or the previous state is recorded as unknown with its code. +1. **Record what is being stopped.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` first and keep its `state=`, `ts=`, `pid=`, `cli=` and `file=` fields for the closing report — after the clear they are gone for good. `state=absent` means no round has finished; say so and still run steps 2 and 3, because a scheduled entry can outlive its heartbeat and `clear` on a missing file is a success, so running both leaves the outcome unambiguous. On exit 2 or 6, follow that code's row, record the previous state as unknown, and carry on to step 2. Completion condition: the previous state and its fields are recorded, or the previous state is recorded as unknown with its code. -2. **Remove the schedule first.** Run `$JSC_HOME/current/jsc-assist/tools/schedule.sh remove all` — both job names, so the patrol entry and any leftover heartbeat entry from an older install both go. This comes before the clear and never after: clear first and the next scheduled round writes a fresh heartbeat over the stopped assistant, and every reader from then on is told a dead assistant is alive. Judge the result by the schedule.sh exit-code table, and keep `removed=` and `others_kept=` for the report. On any non-zero code the schedule is still installed: report the code, say plainly that rounds will keep running and the assistant therefore cannot be stopped, name the manual fix (`crontab -l` to look, then remove the line carrying `# jsc-assist:assistant` by hand), and skip steps 3 and 4 — clearing a heartbeat that the next round rewrites only hides the problem. Completion condition: `remove` exited 0 with its counts recorded, or the failure report has been printed and no stop was claimed. +2. **Remove the schedule first.** Run `{CURRENT}/jsc-assist/tools/schedule.sh remove all` — both job names, so the patrol entry and any leftover heartbeat entry from an older install both go. This comes before the clear and never after: clear first and the next scheduled round writes a fresh heartbeat over the stopped assistant, and every reader from then on is told a dead assistant is alive. Judge the result by the schedule.sh exit-code table, and keep `removed=` and `others_kept=` for the report. On any non-zero code the schedule is still installed: report the code, say plainly that rounds will keep running and the assistant therefore cannot be stopped, name the manual fix (`crontab -l` to look, then remove the line carrying `# jsc-assist:assistant` by hand), and skip steps 3 and 4 — clearing a heartbeat that the next round rewrites only hides the problem. Completion condition: `remove` exited 0 with its counts recorded, or the failure report has been printed and no stop was claimed. -3. **Clear the heartbeat.** Run `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh clear`. On exit 5 the file is still there: report the failure with the script's stderr line and the path, say plainly that every reader still sees a heartbeat claiming a round just finished and that the assistant is therefore not reliably stopped, name the manual fix (delete that path by hand, then run `status` to confirm `助理未運行`), and skip step 4 — the closing notice must not be printed after a failed clear. On exit 2 or 6, follow that code's row and stop the same way. Completion condition: `clear` exited 0, or the failure report naming the code, the path and the manual fix has been printed and no stop was claimed. +3. **Clear the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh clear`. On exit 5 the file is still there: report the failure with the script's stderr line and the path, say plainly that every reader still sees a heartbeat claiming a round just finished and that the assistant is therefore not reliably stopped, name the manual fix (delete that path by hand, then run `status` to confirm `助理未運行`), and skip step 4 — the closing notice must not be printed after a failed clear. On exit 2 or 6, follow that code's row and stop the same way. Completion condition: `clear` exited 0, or the failure report naming the code, the path and the manual fix has been printed and no stop was claimed. 4. **Report the stop and what it means for the gate.** Print the previous state and heartbeat time from step 1 and the entries removed in step 2, then this literally: diff --git a/tools/schedule.sh b/tools/schedule.sh index 09460ad..fa4788a 100755 --- a/tools/schedule.sh +++ b/tools/schedule.sh @@ -17,8 +17,9 @@ # 3 這台機器沒有可用的排程機制:認不得作業系統,或 crontab 與 schtasks 都找不到 # 4 排程操作失敗:讀不到現有排程(且失敗原因不是「沒有排程」)、寫入或刪除回非 0 # 5 回讀驗證失敗:寫入回 0 但條目不在,或移除回 0 但條目還在,又或其他人的條目數量對不上 -# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢, -# 或 --period 給的週期塞不進心跳的過期門檻 +# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢、 +# --period 給的週期塞不進心跳的過期門檻,或 JSC_HOME 解不出絕對路徑(條目裡的根目錄 +# 只要不是絕對路徑,那一輪就叫不到任何工具) # # --- 排程只叫巡檢,心跳由巡檢寫 --- # @@ -80,12 +81,29 @@ # 被擋。頁寫不成就不寫心跳,於是排程裝著卻永遠空轉。所以條目自帶 JSC_GITEA_CONFIRM=yes: # 無人值守的那一輪本來就沒有人可以按同意,擋下來也沒有人會看到。 # +# --- 根目錄從條目餵進去,不由那一輪自己解 --- +# +# 巡檢那一輪要用字面絕對路徑叫工具,所以它得先知道根目錄。那一輪自己解不出來:解路徑的 +# 指令(`readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"`)在無人值守的工作 +# 階段實測一律被擋,連 `ls -d /root/.jsc/current` 這種字面唯讀指令沒有允許規則也照擋。 +# 能寫成字面的話又不必解了。所以根目錄只能從外面餵進去。 +# 本腳本是在機器上、由人叫起來的,解得到根目錄,也解得起。install 於是把解好的字面根目錄 +# 寫進條目的提示文字,那一輪讀提示就拿得到,一個指令都不用跑。 +# 提示裡的格式固定是「工具根目錄={字面絕對路徑}」:技能靠這一段取值,人也讀得懂。 +# 寫進條目的一定是展開後的字面值,不是 $JSC_HOME:條目裡留變數,那一輪拿到的還是變數。 +# # --- 裝完要開哪些權限 --- # # 排程那一輪跑在沒有人的工作階段,跳出權限詢問就是卡住整輪,而且卡到鎖逾時才有下一輪。 # install 成功之後會把那一輪需要的 allow 規則印出來,一行一條。 -# 路徑一律走 $JSC_HOME/current/{外掛名}:那是一組不帶版本的連結,指向該外掛在快取裡的最新 -# 版,由 deploy 維護。規則就是那組確切路徑,比對得準,外掛升版也不用回頭改設定。 +# 實測歸納出兩條判準,印規則一律照它走: +# 一、無人值守時只有允許清單上的完整字面指令跑得動。沒有「預設安全的唯讀指令」這回事: +# `ls -d` 這種指令一樣要有自己的規則,不然照擋。 +# 二、路徑中段的萬用字元不匹配。規則與指令都必須是完整字面,所以規則裡不寫版本號的 +# 萬用字元,也不寫 $JSC_HOME 或 ~。 +# 路徑一律走 $JSC_HOME/current/{外掛名},而且印出來的是展開後的字面值:那是一組不帶版本的 +# 連結,指向該外掛在快取裡的最新版,由 deploy 維護。規則就是那組確切路徑,比對得準,外掛 +# 升版也不用回頭改設定;改指到快取的實體路徑反而會因為帶版本號而每次升版都失效。 # 檔案寫入只認 Edit(...),Write(...) 規則沒有作用,所以不印 Write。 # 連結不在就先警告:那一輪會因為找不到工具而失敗。連結由 deploy 建,本腳本不代建——排程 # 腳本自己去補外掛的部署結構,等於兩個地方管同一件事,壞掉的時候查不出是誰建的。 @@ -157,6 +175,20 @@ die() { # $1=結束碼 $2=訊息 note() { printf '[jsc][助理排程]:%s\n' "$1" >&2; } +# 條目與允許規則共用的字面根目錄。兩邊共用同一個值是刻意的:規則放行哪一組路徑,那一輪就 +# 只能用哪一組路徑叫工具,兩邊各算各的就會差開,而差開的那一輪是被靜靜擋掉,沒有訊號。 +# 相對路徑一律先解成絕對。相對路徑寫進條目等於指向 cron 的工作目錄,那一輪叫不到任何工具。 +ROOT="$CURRENT" +case "$ROOT" in + /*) ;; + *) + _r=$(CDPATH= cd -- "$ROOT" 2>/dev/null && pwd -L) || _r='' + [ -n "$_r" ] || die 6 "JSC_HOME 是相對路徑($JSC_HOME),$ROOT 也解不出絕對路徑。條目與允許規則都需要字面絕對路徑,請把 JSC_HOME 設成絕對路徑再跑一次。" + ROOT="$_r" ;; +esac +# 條目提示文字裡的根目錄那一段。技能靠這一段取值,所以格式固定,不隨 CLI 變。 +ROOT_TOKEN="工具根目錄=$ROOT" + # 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序:先環境變數覆寫,再 # $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。 # current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑另一個 @@ -266,17 +298,18 @@ spec_of() { # 判不出 CLI,或那一支的執行檔不在 PATH 上,都回非 0 由主流程回 6,不猜。 # 執行檔一律用 `command -v` 解成絕對路徑:cron 的 PATH 只有 /usr/bin 與 /bin,裸的指令名 # 每一輪都是 not found,而那一輪不會有人看到錯誤訊息。 +# 每一支 CLI 的提示文字都接上根目錄那一段:那一輪自己解不出根目錄,只能從提示裡拿。 patrol_command() { [ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; } _cli="$CLI" [ -n "$_cli" ] || _cli="${JSC_CLI:-}" [ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; } case "$_cli" in - claude) _bin='claude'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;; - codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢'" ;; - copilot) _bin='copilot'; _args='-p "跑一輪助理巡檢"' ;; - antigravity) _bin='agy'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;; - kiro) _bin='kiro-cli'; _args='-p "跑一輪助理巡檢"' ;; + claude) _bin='claude'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;; + codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢 $ROOT_TOKEN'" ;; + copilot) _bin='copilot'; _args="-p \"跑一輪助理巡檢 $ROOT_TOKEN\"" ;; + antigravity) _bin='agy'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;; + kiro) _bin='kiro-cli'; _args="-p \"跑一輪助理巡檢 $ROOT_TOKEN\"" ;; *) printf '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。\n' >&2 return 1 ;; esac @@ -422,7 +455,14 @@ case "$CMD:$JOBS" in die 6 "$_why" fi ENV_PREFIX=$(env_prefix "$TMPD/snapnames") - SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null) ;; + SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null) + # 提示文字裡沒有根目錄那一段,無人值守那一輪就拿不到根目錄,只能停下回報。本腳本自己 + # 產生的指令一律帶著,所以會走到這裡的只有 --patrol-cmd 與 JSC_ASSIST_PATROL_CMD。 + # 這裡只警告不中止:自訂指令有可能根本不是叫這支技能,中止會把那條路擋掉。 + case "$PATROL_RESOLVED" in + *"$ROOT_TOKEN"*) ;; + *) note "自訂的巡檢指令裡沒有「$ROOT_TOKEN」。那一輪自己解不出根目錄,只能從提示文字拿,拿不到就會停下回報,什麼都不記。請把這一段原樣加進提示文字裡。" ;; + esac ;; esac # --- 裝完要開的權限 --- @@ -444,13 +484,13 @@ print_allow_rules() { # 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的, # 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按 # 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。 - for _s in "$CURRENT/jsc-assist/tools/schedule.sh" \ - "$CURRENT/jsc-assist/tools/patrol.sh" \ - "$CURRENT/jsc-hooks/hooks/heartbeat.sh" \ - "$CURRENT/jsc-hooks/tools/report-status.sh" \ - "$CURRENT/jsc-gitea/tools/gitea.sh" \ - "$CURRENT/jsc-gitea/tools/wiki-contents.sh" \ - "$CURRENT/jsc-gitea/tools/link-check.sh"; do + for _s in "$ROOT/jsc-assist/tools/schedule.sh" \ + "$ROOT/jsc-assist/tools/patrol.sh" \ + "$ROOT/jsc-hooks/hooks/heartbeat.sh" \ + "$ROOT/jsc-hooks/tools/report-status.sh" \ + "$ROOT/jsc-gitea/tools/gitea.sh" \ + "$ROOT/jsc-gitea/tools/wiki-contents.sh" \ + "$ROOT/jsc-gitea/tools/link-check.sh"; do printf 'allow_rule=Bash(%s:*)\n' "$_s" printf 'allow_rule=Bash(sh %s:*)\n' "$_s" printf 'allow_rule=Bash(bash %s:*)\n' "$_s" @@ -467,9 +507,9 @@ print_allow_rules() { check_current_links() { _miss=''; _paths='' for _p in jsc-assist jsc-gitea; do - [ -e "$CURRENT/$_p" ] && continue - if [ -z "$_miss" ]; then _miss="$_p"; _paths="$CURRENT/$_p" - else _miss="$_miss,$_p"; _paths="$_paths、$CURRENT/$_p"; fi + [ -e "$ROOT/$_p" ] && continue + if [ -z "$_miss" ]; then _miss="$_p"; _paths="$ROOT/$_p" + else _miss="$_miss,$_p"; _paths="$_paths、$ROOT/$_p"; fi done if [ -n "$_miss" ]; then printf 'current_links=missing:%s\n' "$_miss" @@ -507,7 +547,7 @@ schtasks_install() { printf 'installed=%s task=%s\n' "$_job" "$_tn" _rc=0 done - printf 'ttl=%s period=%s legacy_removed=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$LOG" + printf 'ttl=%s period=%s legacy_removed=%s patrol_root=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$ROOT" "$LOG" return "$_rc" } @@ -566,8 +606,8 @@ crontab_install() { for _job in $JOBS; do printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)" done - printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s\n' \ - "$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}" + printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s patrol_root=%s\n' \ + "$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}" "$ROOT" printf -- '--- 寫回後的 crontab ---\n' mask_secret <"$_new" return 0 @@ -591,8 +631,8 @@ crontab_install() { for _job in $JOBS; do printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job" | mask_secret)" done - printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s log=%s\n' \ - "$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$LOG" + printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s patrol_root=%s log=%s\n' \ + "$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$ROOT" "$LOG" return 0 } @@ -669,7 +709,8 @@ case "$CMD" in # 一樣要開權限,只是還要先把 cron 服務叫起來。 print_allow_rules check_current_links - note '上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則放行的是 $JSC_HOME/current 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。' + note "上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則與指令都要是完整字面:無人值守時只有清單上的完整字面指令跑得動,沒有預設放行的唯讀指令;路徑中段的萬用字元也不匹配,版本號寫成 * 的規則一樣擋。規則放行的是 $ROOT 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。" + note "條目的提示文字帶著「$ROOT_TOKEN」:那一輪自己解不出根目錄,解路徑的指令在無人值守時一樣被擋,所以根目錄由這一筆條目餵進去。這一段被改掉或刪掉,那一輪會停下回報,什麼都不記。" note "條目帶著安裝當下的環境變數快照(${SNAPSHOT_NAMES:-無}),其中含 Gitea 金鑰:crontab 檔案請保持只有本人讀得到。這幾個變數改過就要重跑一次 install,條目才會跟著換。" [ "$DRYRUN" -eq 1 ] && exit 0 _svc=$(service_state) From e4a3d3ae12be5dc93665247e64a74c7c73955275 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 10:08:56 +0800 Subject: [PATCH 2/3] =?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.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三份 manifest 的版本從 0.1.5 升到 0.1.6,由 sync-skill-manifest.sh 同步。 助理技能的路徑守則與排程條目的內容都改過,安裝端要靠版號才認得出該更新,不升版的話舊條目會一直留在機器上。 版號與主變更分開成一筆,是因為這三個檔案只帶版號、不帶行為,混進主變更會讓那一筆的 diff 夾雜非行為變更;分開之後回溯哪一版對應哪一次變更也看得清楚。 --- .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 ce72a3c..c89158e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.5", + "version": "0.1.6", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2acdf8a..57c516c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.5", + "version": "0.1.6", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/plugin.json b/plugin.json index 62c6e1e..20367e3 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.1.5", + "version": "0.1.6", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { From 711eecac2fec01bd091bf4064e1352fdddf284ac Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 3 Sep 2026 10:31:05 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(assistant):=20=E6=A0=B9=E7=9B=AE?= =?UTF-8?q?=E9=8C=84=E5=AE=88=E9=96=80=E8=A3=9C=E4=B8=8A=E7=9B=AE=E9=8C=84?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E6=AA=A2=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人在現場那一條路解出根目錄之後,守門只看「非空、結束碼 0、是絕對路徑」三項,攔不住 JSC_HOME 沒設的情況。 實測:JSC_HOME 沒設時,readlink -f "$JSC_HOME/current" 印出 /current、結束碼 0。三項全部符合,直接通關,而 /current 並不存在。之後每一條由它組出來的字面路徑都指向不存在的地方,錯誤要到第一支腳本才浮出來,而且訊息看不出根因。 守門加上第四項:印出來的路徑必須是存在的目錄,用 [ -d ] 在同一個已核准的步驟裡查。檢查只掛在人在現場那一條,排程那一輪照舊一個指令都不跑——它的根目錄是安裝排程的人寫進條目的,錯了就由第一支腳本呼叫失敗來反映。 行為契約的關鍵步驟與完成條件兩列跟著改。 --- references/behaviors.md | 4 ++-- skills/assistant/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/references/behaviors.md b/references/behaviors.md index bb2d53c..d8be1de 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`。除了人在現場那一次 `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}`),根目錄一律由外面餵進來:排程那一輪從叫用文字裡的「工具根目錄=」那一段取字面絕對路徑,一個指令都不跑;人在現場叫用時,叫用文字帶那一段就取那一段,沒帶才跑一次 `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` 那一路一律不問。不參與閘門判定 | -| 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解不出來就回報缺 `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 只回報成回報鏈的缺陷,不改寫這一次操作的成敗 | +| 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `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/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index f547b18..81c645f 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -34,7 +34,7 @@ Never work the root out instead. `readlink -f "$JSC_HOME/current"`, `ls -d "$JSC Take the root once per invocation and reuse that one answer. Never resolve it again per call, never print it as a report line of its own, and never add a tool that prints it. Never test the root with a command either — an unattended round cannot, and the first script call is the test that matters anyway. -An empty token, an empty `readlink` result, or a path that is not absolute means there is no root to work with. Report it, say `jsc-cli:deploy` has to run, take the operation's `aborted` status, and stop. Never fall back to a cache path, and never create the root here. Completion condition: one literal absolute path is in hand and every later command line carries it, or the missing root was reported and the operation stopped. +An empty token, an empty `readlink` result, a path that is not absolute, or a resolved path that is not an existing directory means there is no root to work with. **That fourth item is the one the other three wave through.** With `JSC_HOME` unset, `readlink -f "$JSC_HOME/current"` prints `/current` and exits 0 — non-empty, absolute, and past every other item — and each literal path built from it then names a place that is not there. So the attended resolve is only accepted once `[ -d "{the path just printed}" ]` says that directory exists, run in the same approved step as the resolve itself. The unattended round tests nothing, exactly as above: its root was written into the entry by whoever installed the schedule, and its first script call is what fails if that root is wrong. Report it, say `jsc-cli:deploy` has to run, take the operation's `aborted` status, and stop. Never fall back to a cache path, and never create the root here. Completion condition: one literal absolute path is in hand and every later command line carries it, or the missing root was reported and the operation stopped. ## Every script call carries a literal absolute path