diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9c9c082..55aed5a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.2.9", + "version": "0.3.3", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b7b05dc..4c8d770 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.2.9", + "version": "0.3.3", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/README.md b/README.md index 9d516b3..f405964 100644 --- a/README.md +++ b/README.md @@ -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。安裝當下把解好的字面根目錄寫進條目的提示文字(`工具根目錄={絕對路徑}`)並印成 `patrol_root=`:那一輪自己解不出根目錄,只能從提示文字拿,拿不到就停下回報;自訂巡檢指令沒帶這一段只警告、不中止。裝完會檢查排程服務在不在跑,沒跑就回 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_ASSIST_SCAN_ROOT` 與 `JSC_ASSIST_SCAN_EXCLUDE`,以及已設定的 `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` 只印組出來的條目與寫回後的內容,什麼都不動。條目長度兩個模式都量,印成 `entry_len=`:cron 一行有長度上限,超過就整批寫不進去,而那個限制是 `crontab` 自己在寫入那一刻才擋,`--dry-run` 那一路根本不碰它——實測踩過一次,預演全綠、安裝回「command too long」 | | `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/plugin.json b/plugin.json index 9135b82..5d6e02a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.2.9", + "version": "0.3.3", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { diff --git a/references/behaviors.md b/references/behaviors.md index 9d5c011..124c590 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -8,6 +8,6 @@ | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。委派清單改過之後要讓助理的內建定期檢查項跟著重建,也走這一支的 `start`;只想知道差在哪、不要動待辦簿就走 `status`。執行環境健檢不走這支,走 `jsc-cli:doctor`。技能使用次數不走這支,走 `jsc-log:stats` | | 關鍵步驟 | 四個操作都先跑同一個前置步驟,取得工具根目錄(本頁記成 `{CURRENT}`),根目錄一律由外面餵進來:排程那一輪從叫用文字裡的「工具根目錄=」那一段取字面絕對路徑,一個指令都不跑;人在現場叫用時,叫用文字帶那一段就取那一段,沒帶才跑一次 `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"` 自己解,那一次會跳一次權限詢問,人按一下就過。無人值守那一輪取不到根目錄就停下回報:說明條目是舊版 `schedule.sh` 裝的、沒有把根目錄寫進提示文字,叫人重跑一次 `start` 或 `schedule.sh install patrol` 把條目重寫,收尾狀態取 `aborted`;一律不跑 `readlink`、不跑 `ls`、不退回帶變數的路徑、不拿技能提示或上一次轉錄裡的路徑、也不猜。整次叫用只取這一次,之後每一次腳本呼叫都填那一個字面絕對路徑,不是每一次呼叫各取一次,也不另外加印路徑的工具,更不另外跑指令去驗那一個路徑。取到的是空的、不是絕對路徑、或那條路徑不是存在的目錄,就回報根目錄不見了、叫人跑 `jsc-cli:deploy`,收尾狀態取 `aborted`;第四項要單獨查:解析式帶了預設值之後,`JSC_HOME` 沒設不再解成 `/current`,但 `JSC_HOME` 指到已經不存在的目錄、`HOME` 在精簡環境裡沒設、連結農場根本還沒部署,三種都一樣會交出一條非空又絕對、前三項全過得了關的路徑,所以人在現場那一次要在同一步再跑 `[ -d "{剛印出來的路徑}" ]`,目錄存在才算取到根目錄;排程那一輪不查,它的根目錄是裝排程的人寫進條目的,根目錄不對就會在第一支腳本呼叫上失敗。除了人在現場那一次 `readlink`,任何指令列都不得出現 `$JSC_HOME`、`${JSC_HOME}` 或 `~`:權限層比對的是還沒展開的指令字面。實測歸納出兩條判準:一、無人值守時只有允許清單上的完整字面指令跑得動,沒有「預設安全的唯讀指令」這回事,連 `readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"` 與沒有規則的 `ls -d /root/.jsc/current` 都被擋;二、路徑中段的萬用字元不匹配,版本號寫成 `*` 的快取路徑規則一樣擋,規則與指令都必須是完整字面。排程那一輪沒有人可以按同意,被擋就是停在第一支腳本,什麼都不記,心跳也寫不出來。接著認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先跑 `tools/seed-tasks.sh apply --root {CURRENT}` 把內建定期檢查項對齊委派清單(清單在 `{CURRENT}/jsc-meta/tools/delegate-spec.tsv`,根目錄一律用 `--root` 餵進去、那一支自己不解),清單上可交而待辦簿沒有的就加一筆、待辦簿有而清單上已經沒有或改成不交的就移除、`origin` 是 `user` 的一律不動、判定是 `cond` 的預設保留不種入並印出條件原文、`trigger`、`recur` 或 `action` 變了只印 `drift=` 不改那一筆(上游把 `probe` 那一欄合併進來的那一輪,四筆指令型就是走這一條,要換值得人親自帶 `--refresh`);種入與重建是同一個呼叫、冪等,所以每一次 `start` 都跑,第二次跑不會重複建;結束碼 1、2、3 都是「一筆都沒動」,不中止啟動,照實記進收尾回報再往下走,4 是部分失敗、成功的那幾筆算數,一律不帶 `--force` 也不自己帶 `--allow-cond`;接著照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、`patrol_root=`(條目寫進去的字面根目錄,之後每一輪都從那裡讀)、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀五項來源(那一輪另外會自己叫一次 `tools/due.sh scan`,把待辦簿的事件偵測與到期判定寫成「待辦簿到期與逾期」那一節,技能本文一律不自己再叫一次——`scan` 會推進事件快照,同一輪叫第二次就比不出任何事件,而那一次會回報零事件、看起來完全正常;要看下一輪會判出什麼就叫 `due.sh events`,那個子命令一律唯讀)(第五項是執行狀態事件:`collect` 自己叫 `jsc-hooks/tools/report-status.sh drain` 排空,緊接著跑 `rotate`,再把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成監控頁那一節;技能本文一律不自己再跑一次 `drain`)、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把這一輪寫上監控頁、`hash` 是空的就 `abort`、經 `jsc-gitea:wiki` 讀回 `MONITOR_{HASH}` 舊頁、基本資料原樣留著、最新一輪那一塊整塊換成 `latest_file`、`summary_file` 的本輪那一列擺最上面(五欄:巡檢時間、本輪判定、各項成敗、待人處理、警示來源)、舊的資料列接在下面並截到 24 列、三塊重組成整頁、寫回之前先把這一頁要放進去的每一個連結交給 `jsc-gitea/tools/link-check.sh`(結束碼 0 才整頁寫回,結束碼 1 就把 DEAD 那幾筆原樣回報並 `abort`,2、3、7 同樣 `abort`,一個連結都沒有就跳過這一次驗證並照實說明)、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、讀不回舊頁就不寫、監控頁寫成之後跑 `gitea.sh wiki-url` 取那一頁的絕對網址並依結束碼分流(4 回步驟三重寫、5 沒有 `html_url`、7 與 8 走 `abort`,其餘非 0 也走 `abort`,網址取不到就不寫那一個區塊)、換掉 `contents_file` 那個 H2 區塊裡 `{監控頁絕對網址}` 那個佔位、換完再用 `link-check.sh` 驗那一個網址(結束碼 0 才寫那一個區塊;非 0 一律不寫,比照目錄頁結束碼 3 當成那一個區塊沒更新、這一輪照樣往下寫心跳,並把連不到的那一筆列進待人處理)、用 `jsc-gitea/tools/wiki-contents.sh upsert MONITOR 1 "MONITOR_{HASH}" {區塊檔}` 以 H2 標題(也就是內容頁頁名,取 `collect` 印的 `page=`)當鍵更新 `MONITOR_CONTENTS` 自己那一個區塊並一律帶上 `templates/monitor-contents.md` 當範本(第三個參數 `1` 是 `key-col`,只在舊頁還是 markdown 表格時用得到:舊表格第 1 欄「監控頁」持有身分,那一格是 `[MONITOR_{HASH}](網址)`,轉檔時只取文字當標題;頁面已經是條列格式時這個參數被忽略,照樣固定給 `1`)、目錄頁回 3(`CONTENTS` 存取庫沒設定)不中止這一輪,照樣往下寫心跳,並把「設 `JSC_WIKI_REPO_CONTENTS` 或 `JSC_WIKI_REPO`」列進待人處理、監控頁任一失敗或目錄頁其餘非 0 才 `abort` 且不寫心跳、跑 `tools/patrol.sh finish` 寫心跳、最後印出各項結果、本輪事件數與非 ok 事件數、非 ok 事件的明細(kind、name、status、exit、detail)、以及有 start 沒有配對 end 的那幾支技能(單獨列,那代表那一輪中止了)、兩次寫入各自的連結驗證結果(通過、無連結而跳過、或被擋下並附結束碼與 DEAD 明細)、判成警示時的警示來源與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話、最後跑 `tools/seed-tasks.sh plan --root {CURRENT}` 唯讀比對內建項與委派清單並印出差在哪(該加幾筆、還剩幾筆孤兒、保留的 `cond` 各是哪一支、`drift=` 各要換什麼值),一律不跑 `apply`,並說明要套用差異就跑 `start`;那一支回 1、2、3 就照實說比不出來、不說成已對齊。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人。四個操作最後都一樣:回報印完之後跑一次 `jsc-hooks/tools/report-status.sh skill-end jsc-assist:assistant {status} {結束碼}`,`start` 由 hook 記、`end` 由這裡寫,不寫就等於這一次自己看起來中止了 | -| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-assist/tools/due.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{CURRENT}/jsc-gitea/tools/wiki-contents.sh`,連結驗證那一支是 `{CURRENT}/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `{CURRENT}/jsc-hooks/tools/report-status.sh`,範本是 `{CURRENT}/jsc-assist/templates/monitor-contents.md`;`JSC_HOME` 沒設時,人在現場那一次 `readlink` 自己退回 `~/.jsc` 再解,排程那一輪則直接用條目餵進來的值,指令列上不留變數也不留波浪號;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,快取路徑帶版本號,規則寫成萬用字元也對不上,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會把安裝當下偵測到的 CLI 代號記進條目(判法照組 PATH 那一套逐支 `command -v`,不另叫外部腳本;那是往後每一輪判「有沒有少跑一支」的唯一參照基準)、會查 `{CURRENT}/jsc-assist` 與 `{CURRENT}/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把只放得下 CLI 執行檔那幾個目錄的 `PATH`(不是整條照抄——實測整條 2160 字元,crontab 一行有長度上限,照抄會回「command too long」而裝不起來;帶會過期的安裝目錄時重跑一次 `install` 就重新拍一份)、`GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(`PATH` 一定要帶:cron 給的只有 `/usr/bin` 與 `/bin`,而 CLI 執行檔多半裝在家目錄底下,少了它那一輪偵測 CLI 會回結束碼 0 加一份空清單,看起來像這台機器沒裝 CLI;巡檢要跑的指令本身走字面絕對路徑,不靠這一條)(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、刻意不寫死工作階段代號(寫死會讓那一輪的 `start` 與 `end` 落在不同的代號上:前者由 hook 從標準輸入的 JSON 讀 CLI 真正的代號,後者由工具腳本只讀得到環境變數,兩半永遠配不起來,每一輪都被算成一支中止的技能)、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `patrol_root=`、`--patrol-cmd` 或 `JSC_ASSIST_PATROL_CMD` 給的自訂指令沒帶那一段時只警告不中止、並印出這一輪要開的 `allow_rule=` 規則(七支腳本各三種呼叫形式,含 `gitea.sh`、`wiki-contents.sh`、`link-check.sh` 與 `jsc-hooks/tools/report-status.sh`——`Skill(jsc-gitea:wiki)` 只放行叫用技能,技能內部的 Bash 呼叫仍各自受檢;路徑是 `current` 那一組確切路徑,不用萬用字元);七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat`、週期塞不進門檻、判不出 CLI、那一支 CLI 的執行檔不在 `PATH` 上,以及 `JSC_HOME` 解不出絕對路徑(條目寫不出字面根目錄)。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort`、`rebuild` 四個子命令(`collect` 除了 `due_file=` 也轉出 `due_rows_file=`,那是機器可讀的到期清單,執行那一步要拿它當 `--rows`;`rebuild` 拿讀回來的舊頁把三塊接成要寫回去的整頁,`--old` 指舊頁檔、印 `rebuilt_file=` 與 `old_format=`;接法固定所以寫死在腳本裡,不留給呼叫端臨場挑工具——實測某一輪挑了 `awk` 被權限擋掉兩次,那一輪有人在場才繞得過去,而暫存目錄已經累積 124 個同一件事的檔案;基本資料那一塊逐字照抄,連尾端空行都不動),七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是各項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那五項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`、`jsc-hooks/tools/report-status.sh drain` 與 `rotate`,除了排空會把事件流的位移往前推之外全部只讀,任一項失敗不影響其餘各項。`report-status.sh` 三個結束碼各有處置:0 是排空到新事件、3 是沒有新事件(正常狀態,不是失敗)、2 是呼叫寫錯;找不到這一支、`drain` 回 0 與 3 以外的碼、或 `rotate` 回非 0,都只讓這一項標成失敗或記一筆警示,一律不中止那一輪——回報鏈自己壞掉不可以把被回報的那一輪拖下去。`rotate` 只在 `drain` 成功時緊接著跑:中間隔越久,那段時間新寫進來的事件被搬進備份檔而從此排不到的機會越大;排空失敗時位移狀態未知,這時候輪替會直接吃掉還沒排空的那一批。配對以 `session` 加 `name` 為鍵,不只看 `name`:五支 CLI 併發時同一支技能會有好幾個工作階段同時在跑。沒配對到的 `start` 留在 `$JSC_HOME/assistant/events-open.tsv` 跨輪繼續配對,開超過心跳門檻才算疑似中止,未達門檻的算還在跑,超過一天沒配對到就丟掉。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫;只有目錄頁那一個 H2 區塊例外,走 `jsc-gitea/tools/wiki-contents.sh upsert`,它自己解 `CONTENTS` 存取庫、自己讀回整頁比對標題,舊頁還是 markdown 表格時自己先整頁轉成 H2 區塊再寫,七個結束碼各有處置:0 已更新或已新增、1 組不出頁面內容或寫入失敗要 `abort`(找不到同名標題不算錯,那是附加)、2 參數錯就改正重跑(範本路徑不存在也回這一碼,代表 plugin 沒裝齊)、3 是 `CONTENTS` 存取庫未設定且**不中止這一輪**、4 是頁不存在又沒給範本,本技能一律帶第五個參數所以不會出現、7 金鑰失效要 `abort`、8 其他 API 失敗要 `abort`。比對鍵取 H2 標題,也就是內容頁頁名 `MONITOR_{HASH}`,不取「監控頁」那一條的連結:連結含 `GITEA_HOST` 與頁名的網址編碼,那三樣一變鍵就對不上,同一台機器每輪多附一個區塊;頁名只由 `{主機名}/{登入帳號}` 決定,那三樣都動不到它。連結一律寫成 `[{文字}]({絕對網址})`,網址只取 `gitea.sh wiki-url` 印的那一個、不自己組路徑,那一支的結束碼 4、5、7、8 與其餘非 0 各有處置;每一個要放進頁面的連結在寫入前先過 `jsc-gitea/tools/link-check.sh`,它每個網址印一行 `{OK|DEAD|SKIP}` 加網址加說明,五個結束碼各有處置:0 才准寫入、1 有連不到的就不寫並回報 DEAD 那幾筆、2 是一個網址都沒給要補參數重跑、3 是 `GITEA_HOST` 未設定要先設定且不得跳過驗證、7 是金鑰失效要停下來回報金鑰問題而不是當成死連結;驗證走 API 不看網頁狀態碼,私有存取庫的網頁網址對未登入請求一律回 404。頁名雜湊一律取 `gitea.sh hash-id`/`tools/hash-id` 印的完整 40 碼大寫十六進位,不截短、不加前綴、不手算,空輸入回 2。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。待辦簿的存放格式與讀寫入口是本 domain 的 `tools/tasks.sh`:一筆一檔、純文字 key=value、十五個鍵順序固定、值是空的照樣寫出那一行,讀的時候只在第一個等號斷開,寫的時候把值折成一行,一筆一檔的理由同 `restart-required.d`(並行寫入不互相覆寫),`id` 取共用 hash 規則那四十碼的前 8 碼、碰撞時每次加長兩碼,七個子命令 `list`、`add`、`done`、`fail`、`pause`、`resume`、`remove` 與八個結束碼的完整說明寫在那一支的檔頭;第十五個鍵是 `spec_key`,值是 `jsc-{domain}:{技能名}`,那是從一支技能反查到它對應那一筆內建項的唯一把手(`id` 是建立時間加標題的雜湊、反查不了;標題與 `action` 拿來當鍵會撞上使用者交辦的那幾筆),只有 `origin=assistant` 帶得上它,`--spec-key` 配 `--origin user` 回 2,`remove` 對 `origin=user` 的那一筆一律回 7、除非人親自帶 `--force`。內建定期檢查項照委派清單種入與重建的入口是本 domain 的 `tools/seed-tasks.sh`,兩個子命令 `plan`(唯讀預覽)與 `apply`(真的做),清單路徑取 `--root` 餵進來的那一個字面絕對路徑底下的 `jsc-meta/tools/delegate-spec.tsv`;欄位對映是 `trigger` 與 `recur` 原樣抄、`spec_key` 由清單前兩欄合成、`action` 由 `way` 與第十二欄 `probe` 一起推、`title` 固定寫成「委派清單內建項:{spec_key}」以免清單一改就換 `id`、`kind` 一律 `check`、`origin` 一律 `assistant`、`repo` 與 `due` 一律留空,清單的 `verdict`、`slice`、`human`、`next`、`version` 與它自己的 `origin` 欄一律不抄(清單的 `origin` 是 `seed` 或 `judged`,與待辦簿的 `origin` 同名不同義);`action` 那一欄的推法分兩路:`way` 含 `invoke` 就取技能名、`probe` 連看都不看(填了指令會讓整支交出變成只跑一支腳本,那支技能該寫的頁一頁都不會寫,所以那是清單填錯,照 `way` 取技能名並印一行 `probe_bad=`),其餘那幾種交出方式照 `probe` 走——一行指令就取那一行指令、`pending:{理由}` 取 `remind` 並印一行 `pending=`、減號或空的取 `remind`;`probe` 代不進去一律退回 `remind` 並印一行 `probe_bad=`,照樣種入那一筆,涵蓋路徑不是 `{root}/jsc-{domain}/` 開頭、指令裡有金錢符號或波浪號、出現三個代入點以外的大括號、代不出根目錄、代出來的腳本不在這台機器上五種(不種入等於讓上游一格填錯把一筆帶著 `last_run` 與 `fail_count` 的內建項刪掉);`{root}` 由這一支代成 `--root` 給的字面絕對根目錄(沒給就從清單位置往上推三層),那一層的目錄名以 `jsc-{domain}` 為準、找不到才退回不帶前綴的 `{domain}`,而 `{cli}` 與 `{repo}` 刻意留在值裡不展開——那兩件事種入的當下還不知道,種入時展開成多筆會讓同一個 `spec_key` 有好幾個檔案、一致化每一輪只印 `dup=`,而且 CLI 或存取庫一變就要移除再重新登錄、歷史跟著歸零;**`action` 裡出現大括號就是還沒代好的代入點,任何讀取端一律不得原樣拿去執行**,展開由往後接上來的執行那一步負責,`{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,所有目標的結果合起來算這一筆的一次成敗;`pending` 的那幾筆只印在回報裡、待辦檔上一個字都不加(寫進標題會換 `id` 又比不出漂移,另立欄位要動待辦簿那十五個固定的鍵,而理由是清單上會變的散文,抄進去就是抄一份改不掉的舊值);清單只有十一欄、也就是還沒有 `probe` 那一欄時,全部照 `way` 推 `action`、行為與加上那一欄之前一模一樣,只印一行 note 講明整份清單沒有那一欄,不逐列印警告;七個結束碼各有處置:0 對齊完成(零筆改動也算)、1 清單讀不到、2 清單讀到了卻解不出任何可交項目、3 找不到 `tasks.sh`,這三碼一律「一筆都沒動」且不得回報成清單上沒有可交項目,4 是部分失敗、逐筆帶 `tasks.sh` 的結束碼、成功的那幾筆算數,5 檔案系統失敗,6 呼叫寫錯(含 `--root` 不是絕對路徑、`--allow-cond` 形狀不對)。這一支只呼叫 `tasks.sh` 的 `list`、`add`、`remove` 三個子命令,一次都不自己動 `tasks/` 底下的檔案,也一次都不帶 `--force`。事件偵測與到期判定是本 domain 的 `tools/due.sh`,三個子命令 `scan`、`events`、`next`:`scan` 一輪一次,比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期;`events` 是唯讀預覽,一律不推進快照;`next` 是純算,給一組欄位算出 `next_run`。七個結束碼各有處置:0 判完了、1 有狀態來源存在卻讀不到(結果照樣印得出來,那個來源本輪不發事件)、2 有待辦的欄位值判不了(其餘各筆照判)、3 快照換不上去(同一批事件下一輪會被判第二次,要吵出來)、4 待辦簿目錄不存在或零筆(不是失敗,但「沒判過」不等於「都沒到期」)、5 檔案系統失敗、6 呼叫寫錯。事件靠比對狀態快照,一個產生者的腳本都不改:工作包鎖檔轉態、`sessions/{sid}.stage` 換值、`errors/hooks.jsonl` 新增列、`sessions/{sid}.start` 與 `.end`、`worklog-pending` 暫存區清空,各對一個事件名;`analyze-completed:{HASH}` 的來源在 wiki 的分析頁上,要連網才判得出來,這一輪標成未接線並吵出來,不靜靜當成還沒發生,`cron:{式子}` 同樣未接線。快照比對有一個明確的代價:**兩輪之間發生又消失的事件會漏掉**,假設「事件不會漏」就會出錯,而那種錯是無聲的。`tasks/` 底下的檔案只有 `start` 那一步的 `seed-tasks.sh apply` 會經 `tasks.sh` 動到,`patrol`、`status`、`stop` 三個操作一律只讀:`patrol` 一次都不跑 `seed-tasks.sh`(無人值守那一輪移除一筆會把那一筆的 `last_run` 與 `fail_count` 一起弄丟,而清單同步到一半就會刪錯,破壞性清理留給人),`status` 只跑 `plan`、那個子命令一律不寫。代價要講明:沒有人 `start` 也沒有人看的機器上,清單改動要等下一次 `start` 才進得了待辦簿。到期的內建項由本 domain 的 `tools/run-due.sh` 真的執行,兩個子命令 `run` 與 `plan`(`plan` 等同 `run --dry-run`,只印不跑也不回寫),由 `patrol` 那一輪在寫心跳之前叫一次,**到期清單的路徑一律用 `--rows` 餵進去、沒有預設值**(判到期那一支的輸出寫在叫它的人指定的目錄裡,留預設就會讀到別人手動跑留下的舊檔案,而舊檔案讀起來跟新的一模一樣——實測那份舊清單在機器上放了 67 小時,每一輪都被拿去動手,還跑過一筆已經被移除的待辦);清單比心跳門檻還舊就回 2 拒跑,因為比那個長度還舊的清單不可能描述這一輪;另外把偵測到的 CLI 跟排程條目安裝當下記下的那一份比對,少了哪一支就印 `cli_missing=` 並點名(偵測純看 PATH,分不出「裝了但執行檔不在 PATH 上」,而條目的 PATH 是安裝當下拍的,有些 CLI 裝在會過期的目錄底下——沒有基準的話那一輪照樣全部成功、照樣回報都跑過,實測報過「對 4 支都跑過」而機器上裝了 5 支);只報不修;它只跑動作是一行指令、而且 `spec_key` 非空的那幾筆,動作是技能名的、只提醒的、還有使用者親手登錄的一律不跑(叫用整支技能會寫頁、開 PR、改檔案,代價與唯讀盤點差太多,要另外判一次);`{cli}` 由 `jsc-cli/tools/detect-clis.sh` 代入、一個目標跑一次、全部成功那一筆才算成功,`{repo}` 的存取庫掃描還沒做出來所以帶那個代入點的一律只印 `held=` 跳過;執行前再驗一次指令形狀(不得有金錢符號、反引號、波浪號、分號、管線與連接符號,路徑一定要落在這一輪的根目錄底下,代完之後不得留大括號),驗不過同樣只印 `held=`;**`held=` 那幾筆的 `last_run` 與 `fail_count` 一個字都不動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數蓋掉真的壞掉的那幾筆;成功回頭叫 `tasks.sh done`、下一次到期由 `due.sh next` 算,失敗回頭叫 `tasks.sh fail`,失敗照重試、不自動退讓;六個結束碼各有處置:0 這一輪處理完(零筆到期與全部 `held=` 都算)、1 有指令回非零(那一筆已記成失敗,是發現不是故障)、2 到期清單讀不到或欄位對不上因而一筆都沒跑(不得讀成「都沒到期」)、4 有回寫失敗(跑了沒記到,下一輪會再跑一次同一筆)、5 檔案系統失敗、6 呼叫寫錯,**任何一碼都不中止那一輪**。`tasks.sh` 的 `pause` 與 `resume` 兩個子命令還沒有任何一個操作呼叫得到:登錄時的補問流程、逾期與失敗的處理行為、`remind` 怎麼送到前景、待辦簿的 wiki 雙向同步,四項都還沒接上去,所以到期的那幾筆這一輪只印出來、不執行,也不回寫 `last_run`;執行那一步接上來的時候,代入點的展開歸它負責——`action` 帶大括號的那幾筆要先把 `{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,代不出目標就當這一筆這一輪沒得跑並回報,一律不得把帶大括號的字面值原樣送進殼。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | +| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-assist/tools/due.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{CURRENT}/jsc-gitea/tools/wiki-contents.sh`,連結驗證那一支是 `{CURRENT}/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `{CURRENT}/jsc-hooks/tools/report-status.sh`,範本是 `{CURRENT}/jsc-assist/templates/monitor-contents.md`;`JSC_HOME` 沒設時,人在現場那一次 `readlink` 自己退回 `~/.jsc` 再解,排程那一輪則直接用條目餵進來的值,指令列上不留變數也不留波浪號;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,快取路徑帶版本號,規則寫成萬用字元也對不上,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會把安裝當下偵測到的 CLI 代號記進條目(判法照組 PATH 那一套逐支 `command -v`,不另叫外部腳本;那是往後每一輪判「有沒有少跑一支」的唯一參照基準)、會查 `{CURRENT}/jsc-assist` 與 `{CURRENT}/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把只放得下 CLI 執行檔那幾個目錄的 `PATH`(不是整條照抄——實測整條 2160 字元,crontab 一行有長度上限,照抄會回「command too long」而裝不起來;帶會過期的安裝目錄時重跑一次 `install` 就重新拍一份)、`GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL`、`JSC_ASSIST_SCAN_ROOT`、`JSC_ASSIST_SCAN_EXCLUDE` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(掃描起點少了它,帶 `{repo}` 的內建項每一輪都代不出目標,而那一輪只印一行 `held=`,看起來像這一批還沒接上、不像一個變數沒進條目)(`PATH` 一定要帶:cron 給的只有 `/usr/bin` 與 `/bin`,而 CLI 執行檔多半裝在家目錄底下,少了它那一輪偵測 CLI 會回結束碼 0 加一份空清單,看起來像這台機器沒裝 CLI;巡檢要跑的指令本身走字面絕對路徑,不靠這一條)(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、刻意不寫死工作階段代號(寫死會讓那一輪的 `start` 與 `end` 落在不同的代號上:前者由 hook 從標準輸入的 JSON 讀 CLI 真正的代號,後者由工具腳本只讀得到環境變數,兩半永遠配不起來,每一輪都被算成一支中止的技能)、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `patrol_root=`、`--patrol-cmd` 或 `JSC_ASSIST_PATROL_CMD` 給的自訂指令沒帶那一段時只警告不中止、並印出這一輪要開的 `allow_rule=` 規則(七支腳本各三種呼叫形式,含 `gitea.sh`、`wiki-contents.sh`、`link-check.sh` 與 `jsc-hooks/tools/report-status.sh`——`Skill(jsc-gitea:wiki)` 只放行叫用技能,技能內部的 Bash 呼叫仍各自受檢;路徑是 `current` 那一組確切路徑,不用萬用字元);七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat`、週期塞不進門檻、判不出 CLI、那一支 CLI 的執行檔不在 `PATH` 上,以及 `JSC_HOME` 解不出絕對路徑(條目寫不出字面根目錄)。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort`、`rebuild` 四個子命令(`collect` 除了 `due_file=` 也轉出 `due_rows_file=`,那是機器可讀的到期清單,執行那一步要拿它當 `--rows`;`rebuild` 拿讀回來的舊頁把三塊接成要寫回去的整頁,`--old` 指舊頁檔、印 `rebuilt_file=` 與 `old_format=`;接法固定所以寫死在腳本裡,不留給呼叫端臨場挑工具——實測某一輪挑了 `awk` 被權限擋掉兩次,那一輪有人在場才繞得過去,而暫存目錄已經累積 124 個同一件事的檔案;基本資料那一塊逐字照抄,連尾端空行都不動),七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是各項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那五項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`、`jsc-hooks/tools/report-status.sh drain` 與 `rotate`,除了排空會把事件流的位移往前推之外全部只讀,任一項失敗不影響其餘各項。`report-status.sh` 三個結束碼各有處置:0 是排空到新事件、3 是沒有新事件(正常狀態,不是失敗)、2 是呼叫寫錯;找不到這一支、`drain` 回 0 與 3 以外的碼、或 `rotate` 回非 0,都只讓這一項標成失敗或記一筆警示,一律不中止那一輪——回報鏈自己壞掉不可以把被回報的那一輪拖下去。`rotate` 只在 `drain` 成功時緊接著跑:中間隔越久,那段時間新寫進來的事件被搬進備份檔而從此排不到的機會越大;排空失敗時位移狀態未知,這時候輪替會直接吃掉還沒排空的那一批。配對以 `session` 加 `name` 為鍵,不只看 `name`:五支 CLI 併發時同一支技能會有好幾個工作階段同時在跑。沒配對到的 `start` 留在 `$JSC_HOME/assistant/events-open.tsv` 跨輪繼續配對,開超過心跳門檻才算疑似中止,未達門檻的算還在跑,超過一天沒配對到就丟掉。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫;只有目錄頁那一個 H2 區塊例外,走 `jsc-gitea/tools/wiki-contents.sh upsert`,它自己解 `CONTENTS` 存取庫、自己讀回整頁比對標題,舊頁還是 markdown 表格時自己先整頁轉成 H2 區塊再寫,七個結束碼各有處置:0 已更新或已新增、1 組不出頁面內容或寫入失敗要 `abort`(找不到同名標題不算錯,那是附加)、2 參數錯就改正重跑(範本路徑不存在也回這一碼,代表 plugin 沒裝齊)、3 是 `CONTENTS` 存取庫未設定且**不中止這一輪**、4 是頁不存在又沒給範本,本技能一律帶第五個參數所以不會出現、7 金鑰失效要 `abort`、8 其他 API 失敗要 `abort`。比對鍵取 H2 標題,也就是內容頁頁名 `MONITOR_{HASH}`,不取「監控頁」那一條的連結:連結含 `GITEA_HOST` 與頁名的網址編碼,那三樣一變鍵就對不上,同一台機器每輪多附一個區塊;頁名只由 `{主機名}/{登入帳號}` 決定,那三樣都動不到它。連結一律寫成 `[{文字}]({絕對網址})`,網址只取 `gitea.sh wiki-url` 印的那一個、不自己組路徑,那一支的結束碼 4、5、7、8 與其餘非 0 各有處置;每一個要放進頁面的連結在寫入前先過 `jsc-gitea/tools/link-check.sh`,它每個網址印一行 `{OK|DEAD|SKIP}` 加網址加說明,五個結束碼各有處置:0 才准寫入、1 有連不到的就不寫並回報 DEAD 那幾筆、2 是一個網址都沒給要補參數重跑、3 是 `GITEA_HOST` 未設定要先設定且不得跳過驗證、7 是金鑰失效要停下來回報金鑰問題而不是當成死連結;驗證走 API 不看網頁狀態碼,私有存取庫的網頁網址對未登入請求一律回 404。頁名雜湊一律取 `gitea.sh hash-id`/`tools/hash-id` 印的完整 40 碼大寫十六進位,不截短、不加前綴、不手算,空輸入回 2。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。待辦簿的存放格式與讀寫入口是本 domain 的 `tools/tasks.sh`:一筆一檔、純文字 key=value、十五個鍵順序固定、值是空的照樣寫出那一行,讀的時候只在第一個等號斷開,寫的時候把值折成一行,一筆一檔的理由同 `restart-required.d`(並行寫入不互相覆寫),`id` 取共用 hash 規則那四十碼的前 8 碼、碰撞時每次加長兩碼,七個子命令 `list`、`add`、`done`、`fail`、`pause`、`resume`、`remove` 與八個結束碼的完整說明寫在那一支的檔頭;第十五個鍵是 `spec_key`,值是 `jsc-{domain}:{技能名}`,那是從一支技能反查到它對應那一筆內建項的唯一把手(`id` 是建立時間加標題的雜湊、反查不了;標題與 `action` 拿來當鍵會撞上使用者交辦的那幾筆),只有 `origin=assistant` 帶得上它,`--spec-key` 配 `--origin user` 回 2,`remove` 對 `origin=user` 的那一筆一律回 7、除非人親自帶 `--force`。內建定期檢查項照委派清單種入與重建的入口是本 domain 的 `tools/seed-tasks.sh`,兩個子命令 `plan`(唯讀預覽)與 `apply`(真的做),清單路徑取 `--root` 餵進來的那一個字面絕對路徑底下的 `jsc-meta/tools/delegate-spec.tsv`;欄位對映是 `trigger` 與 `recur` 原樣抄、`spec_key` 由清單前兩欄合成、`action` 由 `way` 與第十二欄 `probe` 一起推、`title` 固定寫成「委派清單內建項:{spec_key}」以免清單一改就換 `id`、`kind` 一律 `check`、`origin` 一律 `assistant`、`repo` 與 `due` 一律留空,清單的 `verdict`、`slice`、`human`、`next`、`version` 與它自己的 `origin` 欄一律不抄(清單的 `origin` 是 `seed` 或 `judged`,與待辦簿的 `origin` 同名不同義);`action` 那一欄的推法分兩路:`way` 含 `invoke` 就取技能名、`probe` 連看都不看(填了指令會讓整支交出變成只跑一支腳本,那支技能該寫的頁一頁都不會寫,所以那是清單填錯,照 `way` 取技能名並印一行 `probe_bad=`),其餘那幾種交出方式照 `probe` 走——一行指令就取那一行指令、`pending:{理由}` 取 `remind` 並印一行 `pending=`、減號或空的取 `remind`;`probe` 代不進去一律退回 `remind` 並印一行 `probe_bad=`,照樣種入那一筆,涵蓋路徑不是 `{root}/jsc-{domain}/` 開頭、指令裡有金錢符號或波浪號、出現三個代入點以外的大括號、代不出根目錄、代出來的腳本不在這台機器上五種(不種入等於讓上游一格填錯把一筆帶著 `last_run` 與 `fail_count` 的內建項刪掉);`{root}` 由這一支代成 `--root` 給的字面絕對根目錄(沒給就從清單位置往上推三層),那一層的目錄名以 `jsc-{domain}` 為準、找不到才退回不帶前綴的 `{domain}`,而 `{cli}` 與 `{repo}` 刻意留在值裡不展開——那兩件事種入的當下還不知道,種入時展開成多筆會讓同一個 `spec_key` 有好幾個檔案、一致化每一輪只印 `dup=`,而且 CLI 或存取庫一變就要移除再重新登錄、歷史跟著歸零;**`action` 裡出現大括號就是還沒代好的代入點,任何讀取端一律不得原樣拿去執行**,展開由往後接上來的執行那一步負責,`{cli}` 換成每一支偵測到的 CLI 代號、`{repo}` 換成每一個掃到的存取庫工作目錄,一個目標跑一次,所有目標的結果合起來算這一筆的一次成敗;`pending` 的那幾筆只印在回報裡、待辦檔上一個字都不加(寫進標題會換 `id` 又比不出漂移,另立欄位要動待辦簿那十五個固定的鍵,而理由是清單上會變的散文,抄進去就是抄一份改不掉的舊值);清單只有十一欄、也就是還沒有 `probe` 那一欄時,全部照 `way` 推 `action`、行為與加上那一欄之前一模一樣,只印一行 note 講明整份清單沒有那一欄,不逐列印警告;七個結束碼各有處置:0 對齊完成(零筆改動也算)、1 清單讀不到、2 清單讀到了卻解不出任何可交項目、3 找不到 `tasks.sh`,這三碼一律「一筆都沒動」且不得回報成清單上沒有可交項目,4 是部分失敗、逐筆帶 `tasks.sh` 的結束碼、成功的那幾筆算數,5 檔案系統失敗,6 呼叫寫錯(含 `--root` 不是絕對路徑、`--allow-cond` 形狀不對)。這一支只呼叫 `tasks.sh` 的 `list`、`add`、`remove` 三個子命令,一次都不自己動 `tasks/` 底下的檔案,也一次都不帶 `--force`。事件偵測與到期判定是本 domain 的 `tools/due.sh`,三個子命令 `scan`、`events`、`next`:`scan` 一輪一次,比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期;`events` 是唯讀預覽,一律不推進快照;`next` 是純算,給一組欄位算出 `next_run`。七個結束碼各有處置:0 判完了、1 有狀態來源存在卻讀不到(結果照樣印得出來,那個來源本輪不發事件)、2 有待辦的欄位值判不了(其餘各筆照判)、3 快照換不上去(同一批事件下一輪會被判第二次,要吵出來)、4 待辦簿目錄不存在或零筆(不是失敗,但「沒判過」不等於「都沒到期」)、5 檔案系統失敗、6 呼叫寫錯。事件靠比對狀態快照,一個產生者的腳本都不改:工作包鎖檔轉態、`sessions/{sid}.stage` 換值、`errors/hooks.jsonl` 新增列、`sessions/{sid}.start` 與 `.end`、`worklog-pending` 暫存區清空,各對一個事件名;`analyze-completed:{HASH}` 的來源在 wiki 的分析頁上,要連網才判得出來,這一輪標成未接線並吵出來,不靜靜當成還沒發生,`cron:{式子}` 同樣未接線。快照比對有一個明確的代價:**兩輪之間發生又消失的事件會漏掉**,假設「事件不會漏」就會出錯,而那種錯是無聲的。`tasks/` 底下的檔案只有 `start` 那一步的 `seed-tasks.sh apply` 會經 `tasks.sh` 動到,`patrol`、`status`、`stop` 三個操作一律只讀:`patrol` 一次都不跑 `seed-tasks.sh`(無人值守那一輪移除一筆會把那一筆的 `last_run` 與 `fail_count` 一起弄丟,而清單同步到一半就會刪錯,破壞性清理留給人),`status` 只跑 `plan`、那個子命令一律不寫。代價要講明:沒有人 `start` 也沒有人看的機器上,清單改動要等下一次 `start` 才進得了待辦簿。到期的內建項由本 domain 的 `tools/run-due.sh` 真的執行,兩個子命令 `run` 與 `plan`(`plan` 等同 `run --dry-run`,只印不跑也不回寫),由 `patrol` 那一輪在寫心跳之前叫一次,**到期清單的路徑一律用 `--rows` 餵進去、沒有預設值**(判到期那一支的輸出寫在叫它的人指定的目錄裡,留預設就會讀到別人手動跑留下的舊檔案,而舊檔案讀起來跟新的一模一樣——實測那份舊清單在機器上放了 67 小時,每一輪都被拿去動手,還跑過一筆已經被移除的待辦);清單比心跳門檻還舊就回 2 拒跑,因為比那個長度還舊的清單不可能描述這一輪;另外把偵測到的 CLI 跟排程條目安裝當下記下的那一份比對,少了哪一支就印 `cli_missing=` 並點名(偵測純看 PATH,分不出「裝了但執行檔不在 PATH 上」,而條目的 PATH 是安裝當下拍的,有些 CLI 裝在會過期的目錄底下——沒有基準的話那一輪照樣全部成功、照樣回報都跑過,實測報過「對 4 支都跑過」而機器上裝了 5 支);只報不修;它只跑動作是一行指令、而且 `spec_key` 非空的那幾筆,動作是技能名的、只提醒的、還有使用者親手登錄的一律不跑(叫用整支技能會寫頁、開 PR、改檔案,代價與唯讀盤點差太多,要另外判一次);`{cli}` 由 `jsc-cli/tools/detect-clis.sh` 代入、`{repo}` 由本 domain 的 `tools/scan-repos.sh` 代入,一個目標跑一次、全部成功那一筆才算成功,兩個代入點都帶的那一筆目標數是兩邊的乘積;那一筆的 `repo` 欄有值時只代那一個,值可以是工作目錄路徑也可以是 `{owner}/{repo}`、對不出掃到的那一份就印 `held=` 而不退回全部存取庫(那一筆指名了一個目標,代成全部就是跑了十九個沒有人要它跑的地方);掃描起點沒設定時掃描那一支回 3、什麼都不交,帶 `{repo}` 的那幾筆照它印的理由原樣記進 `held=`(那不是漏填預設值,是刻意不猜:猜錯掃描起點就是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到),並印 `repos=` 與 `repo_scan=` 兩個欄位,掃描那一支的 `rejected=`、`unnamed=`、`excluded=`、`skipped_link=`、`not_a_repo=` 逐行原樣轉出成 `repo_scan_note=`(那幾個是真的存在卻沒被盤點到的存取庫,只印一個數字會讀成整批都掃過了);執行前再驗一次指令形狀(不得有金錢符號、反引號、波浪號、分號、管線與連接符號,路徑一定要落在這一輪的根目錄底下,代完之後不得留大括號),驗不過同樣只印 `held=`;**`held=` 那幾筆的 `last_run` 與 `fail_count` 一個字都不動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數蓋掉真的壞掉的那幾筆;成功回頭叫 `tasks.sh done`、下一次到期由 `due.sh next` 算,失敗回頭叫 `tasks.sh fail`,失敗照重試、不自動退讓;六個結束碼各有處置:0 這一輪處理完(零筆到期與全部 `held=` 都算)、1 有指令回非零(那一筆已記成失敗,是發現不是故障)、2 到期清單讀不到或欄位對不上因而一筆都沒跑(不得讀成「都沒到期」)、4 有回寫失敗(跑了沒記到,下一輪會再跑一次同一筆)、5 檔案系統失敗、6 呼叫寫錯,**任何一碼都不中止那一輪**。`tasks.sh` 的 `pause` 與 `resume` 兩個子命令還沒有任何一個操作呼叫得到:登錄時的補問流程、逾期與失敗的處理行為、`remind` 怎麼送到前景、待辦簿的 wiki 雙向同步,四項都還沒接上去;到期的那幾筆由 `run-due.sh` 執行並回寫 `last_run`,但只限動作是一行指令、`spec_key` 非空的那幾筆,動作是技能名的與只提醒的照舊只印出來。代入點的展開歸執行那一步負責,一律不得把帶大括號的字面值原樣送進殼。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。被權限層擋下的指令,回報一律帶完整指令列原文(含環境變數前綴)並指出是哪一步送出的:擋下不是工具回的錯誤,沒有結束碼、沒有 stderr、工具自己的日誌上也沒有痕跡,指令字面是唯一的證據,也是唯一能拿去對允許清單的東西——而清單比對的是還沒展開的指令字面。實測踩過一次:某一輪只寫「寫入被核准機制擋下」,沒說是哪一支工具、哪一條指令,事後把明顯的嫌疑一個一個排除掉,真正那一條始終沒找到。不參與閘門判定 | | 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解出來的要是一條存在的絕對路徑(同一步用 `[ -d ]` 查過),解不出來或目錄不存在就回報缺 `current` 並中止,同樣取 `aborted`。`start` 要先跑過一次 `seed-tasks.sh apply` 並把它的結束碼、`added=`、`removed=`、`kept=`、`drift=`、`held=`、`bad=`、`probe=`、`pending=`、`probe_bad=` 各數字與逐列 `held=`、`bad=`、`drift=`、`dup=`、`skip_user=`、`probe=`、`pending=`、`probe_bad=` 記進收尾回報(`pending=` 那幾筆要寫成「有唯讀盤點入口、還沒接上」並附清單上的理由原文,不得跟本來就只提醒的那幾筆混成一句;`probe=` 那幾筆的 `holes=` 不是減號時要寫明還留著哪幾個代入點)(回 1、2、3 時要寫成「內建項原樣沒動」並附原因,不得寫成「沒有可交項目」),然後要那一輪巡檢的 `finish` 回 0 且 `report` 回 `state=fresh`,才算啟動成功;巡檢沒寫成心跳一律回報失敗並停下,不得宣稱啟動;`schedule.sh install patrol` 回 1 要講明條目不會被執行與 `sudo service cron start`,不得宣稱排程會定時執行;回 0 或 1 都要把 `allow_rule=` 各行、「條目含金鑰快照、變數改了要重裝」這句提醒,以及 `current` 連結缺漏的警告轉出去。`patrol` 要五項各自有 `status`、「待辦簿到期與逾期」那一節要有逐筆判定表(`due.sh` 回 0、1、2、3、4 都算判過,其中 1、2、3 各記一筆警示與一列待人處理;回別的碼就照實寫「這一輪判不出到期」並說明那不代表沒有任何一筆到期,不留空白也不寫成「都沒到期」)、執行狀態事件那一項要印出本輪事件數、非 ok 事件數與未配對的 `start`(`drain` 回 3 是沒有新事件,照樣算這一項讀到底)、監控頁那一頁要放的連結全部通過 `link-check.sh`(或整頁本來就沒有連結)、監控頁三塊重組寫成、目錄頁那一個 H2 區塊的網址通過 `link-check.sh` 後更新成功,或以目錄頁結束碼 3、或以連結驗證非 0 回報成沒更新、`finish` 回 0,才算一輪跑完;`collect` 回 4 是讓開,不算失敗也不寫任何東西;舊頁讀不回來就不寫,回報「這一輪沒有結果」;連結驗證沒過就不寫那一頁,監控頁沒寫成就 `abort`,心跳一定不寫;目錄頁除了結束碼 3 之外的非 0 也一樣 `abort`,結束碼 3 只少一筆索引,那一輪的結果已經在監控頁上,照樣寫心跳並把缺的變數列進待人處理;目錄頁那一個區塊的連結驗不過同樣只少一筆索引,照樣寫心跳並把那一筆列進待人處理。`status` 要印出現況表,或印出「助理未運行」並說明原因,並且要多印一段內建項與委派清單的差異(該加幾筆、還剩幾筆孤兒、保留的 `cond` 各是哪一支、`drift=` 各要換什麼值,以及「要套用就跑 `start`」這句話),那一段比不出來就照實說比不出來;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝、清單讀不到,五種都算正常結束。`stop` 要 `schedule.sh remove all` 先回 0、`clear` 再回 0,並印出帶三段話的停止訊息;`remove` 非 0 就回報排程還在、助理停不掉,不清心跳也不印停止訊息;`clear` 回 5 就回報心跳檔還在、助理沒有確實停掉,不印停止訊息。四個操作都要在回報之後寫一筆 `skill-end`,`status` 取 ok、blocked、failed、degraded、aborted 五選一,要與回報出去的結果一致;那一支回非 0 只回報成回報鏈的缺陷,不改寫這一次操作的成敗 | | 可驗證跡象 | 四個操作的轉錄裡,每一條指令列都是字面絕對路徑,開頭是 `/`,沒有 `$JSC_HOME`、`${JSC_HOME}` 或 `~`,也沒有任何一次因為路徑帶變數而跳出來的權限詢問;排程那一輪從頭到尾一次 `readlink`、一次 `ls` 都沒有,根目錄直接取自叫用文字;人在現場那一路才可能有 `readlink`,而且同一次叫用只出現一次。`start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照,提示文字裡有「工具根目錄=」接一個字面絕對路徑,那個值與 install 印的 `patrol_root=` 和 `allow_rule=` 用的根目錄完全相同,不是變數也不是快取實體路徑;install 印出的 `allow_rule=` 都是 current 那一組展開後的字面絕對路徑,沒有變數、沒有波浪號、沒有萬用字元,也沒有 `Write(...)`,而且 `jsc-gitea/tools/link-check.sh` 與 `jsc-hooks/tools/report-status.sh` 那三種呼叫形式都在裡面。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 只有三塊:基本資料一字未改、最新一輪換成本輪、摘要表最上面一列是本輪且總列數不超過 24,頁名的 `{HASH}` 是 40 碼大寫十六進位,雜湊來源那一列寫的是不含網域的短主機名;`CONTENTS` 存取庫裡的 `MONITOR_CONTENTS` 只有自己那一個 H2 區塊變動,同一台機器從頭到尾只有一個區塊,標題是 `MONITOR_` 接 40 碼大寫十六進位、標題上不帶連結也不帶網址,區塊裡「監控頁」那一條是 `[{頁名}]({絕對網址})` 這種連結、點下去開得起那一頁,「HASH」那一條是裸 HASH、40 碼大寫十六進位、不帶連結,八條欄位一條都不缺、格式是 `- {欄位名}:{值}`,頁上一個 markdown 表格都不剩,兩頁上點得到的連結沒有一個是死的——把頁上的網址抓出來重跑一次 `link-check.sh`,應該全部是 `OK`、結束碼 0,別台機器的區塊一字不動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `latest.md`、`summary.md`、`summary-row.md`、`newpage.md`、`contents-entry.md`,摘要列是五欄、警示來源那一欄有值或寫「無」;兩支腳本不是從 current 那一組路徑跑起來時,stderr 會有一行 `[WARN]` 點出實際路徑與應該用的路徑,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/events-prev.tsv` 換成本輪的狀態快照(三欄定位字元分隔,每一個來源另有一列 `meta`)、`$JSC_HOME/assistant/events-seen.tsv` 是每一個事件名的累計次數與最後發生時間,`$JSC_HOME/assistant/patrol/due/` 底下有本輪的 `due.md` 與 `rows.txt`(`rows.txt` 是十一欄定位字元分隔,欄位順序印在 `rows_columns=`;要逐筆取值就讀它,不要切 `task=` 那幾行,那幾行的四個欄位都可能帶空白),第一次跑那一輪的 `due.sh` 印 `first_run=1`、事件數為 0,且 `tasks/` 底下一個檔案都沒被改動,`$JSC_HOME/assistant/patrol.lock` 已經放掉;監控頁的最新一輪有「執行狀態事件」那一節,節裡有本輪事件數、非 ok 事件數,以及非 ok 明細與未配對 `start` 兩張表(一筆都沒有時寫明「沒有」,不留空表格);`$JSC_HOME/usage/scan-state/events.offset` 的數字往前推到本輪排空的位置,`$JSC_HOME/assistant/events-open.tsv` 只剩下還沒配對到 `end` 的那幾筆。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作跑完,`$JSC_HOME/usage/events.jsonl` 最後都多一筆 `name` 是 `jsc-assist:assistant`、`phase` 是 `end` 的事件,`status` 與回報出去的結果一致,而且同一個 `session` 下它與 hook 記的那一筆 `phase=start` 配得起來。`patrol`、`status`、`stop` 三個操作都不動 `tasks/` 底下的檔案;`start` 只在第一步經 `tasks.sh` 動內建項,動完之後 `tasks/` 底下每一個 `origin=user` 的檔案內容與修改時間都一字未改,`origin=assistant` 且帶 `spec_key` 的那幾筆與委派清單上非 `none`、非 `cond` 的那幾列一對一對得上(同一個 `spec_key` 只有一個檔案),`spec_key` 是空的那幾筆一筆都沒被加也沒被刪,判定是 `cond` 的那幾支在 `tasks/` 底下找不到對應檔案而回報裡逐支有一行 `held=`;`way` 只有 `patrol` 或 `remind`、而 `probe` 是一行指令的那幾筆,`action=` 那一行是完整字面絕對指令,開頭是 `/` 或一個大寫環境變數指派,路徑中段沒有版本號、沒有金錢符號、沒有波浪號,代入點只可能剩 `{cli}` 或 `{repo}`,而且那幾行在回報裡各有一行 `probe=` 對得上;`probe` 是 `pending:` 的那幾筆 `action=remind`、待辦檔上看不出跟本來就只提醒的那幾筆有什麼不同,差別只在回報裡的 `pending=` 那幾行;清單只有十一欄時 `probe=`、`pending=`、`probe_bad=` 三個數字全是 0,而且 stderr 有一行 note 講明那一份清單沒有第十二欄。四個操作都不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | diff --git a/skills/assistant/SKILL.md b/skills/assistant/SKILL.md index 68ab0c2..013555d 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -76,6 +76,7 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta | the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` | | the built-in check items, reconciled against the delegation list | `{CURRENT}/jsc-assist/tools/seed-tasks.sh` | | the due built-in check items, actually run | `{CURRENT}/jsc-assist/tools/run-due.sh` | +| the repositories under the working directory, scanned for `{repo}` | `{CURRENT}/jsc-assist/tools/scan-repos.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` | @@ -185,7 +186,7 @@ Four properties of that script matter enough to state here, because a report tha - **A written entry is not a running entry.** WSL does not start cron by default, and this is the machine's most likely state. Exit 1 from `install` means the entry is on disk and will never fire. Report that as a failure of the start, name `sudo service cron start`, and say it has to be run again after every WSL restart. Never soften exit 1 into "scheduling is set up". - **The log lives at `$JSC_HOME/assistant/schedule.log`**, deliberately outside every repository. Do not offer to move it into a project. - **The entry runs with no human present.** The command is installed with `` 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. @@ -398,7 +400,7 @@ One round: read five sources, record the result, then beat. Everything before th Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran. -5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was. +5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **`repos=` and `repo_scan=` are the same kind of reading for `{repo}`.** `repos=0` with `repo_scan=rc3` means the scan had no starting point, so every entry carrying `{repo}` was held this round — report the variable to set, not "there is nothing to inventory". `repo_scan=rc1` means the scan handed over what it could and named the rest: each `repo_scan_note=` line is a directory the scan looked at and did not hand over whole: a path carrying a space or a shell metacharacter, a repository with no `origin` to compute a `REPO_{HASH}` page name from, or a `not_a_repo=` — a directory holding a `.git` that git itself refuses, which is a leftover rather than a repository waiting to be wired. Those lines go into the report as findings; a round that prints only the count reads as a full sweep. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was. 6. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code. diff --git a/tools/run-due.sh b/tools/run-due.sh index d913a3b..52dd554 100755 --- a/tools/run-due.sh +++ b/tools/run-due.sh @@ -46,12 +46,16 @@ # # --- 代不出目標不算失敗 --- # -# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來, -# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的 -# last_run,也不加失敗次數**。 -# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數 +# {cli} 由偵測到的 CLI 代號代入,{repo} 由 scan-repos.sh 掃到的存取庫工作目錄代入。 +# 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run, +# 也不加失敗次數**。 +# 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數 # 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。 # 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。 +# +# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支 +# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到 +# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。 set -u usage() { @@ -60,6 +64,16 @@ usage() { } die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; } + +# 取第一行、截到指定長度。cut 數的是位元組不是字元,中文字剛好被切成一半時尾巴就變成 +# 替代字元——實測踩到:一行中文錯誤訊息截在第 200 個位元組,報告上留下一個亂碼字。 +# 截完再過一次 iconv -c 把那個不完整的序列丟掉。iconv 不在這台機器上就退回原樣, +# 不自己拼一套 UTF-8 邊界判定:那是在錯誤訊息這條路上加一個會出錯的新東西。 +clip() { # $1=位元組上限;讀標準輸入 + _raw=$(head -n1 | cut -c"1-$1") + _cln=$(printf '%s' "$_raw" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null) + if [ -n "$_cln" ]; then printf '%s' "$_cln"; else printf '%s' "$_raw"; fi +} note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; } warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; } @@ -186,9 +200,52 @@ if [ -n "${JSC_ASSIST_CLIS:-}" ]; then done fi -# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令, -# 而那一輪沒有人看得到它跑到哪裡去了。 -REPO_READY=0 +# --- {repo} 的代入來源 --- + +# 掃到的存取庫工作目錄,一行一條。掃描規則、掃描起點與排除清單全在 scan-repos.sh 那一支, +# 這裡不自己再判一次:兩邊各有一套掃描規則就會漂移,而漂移的那一天沒有人會收到通知。 +# 兩份:REPOS 是路徑清單,給沒綁存取庫的那幾筆代成每一個目標;REPOMAP 多帶一欄 +# {owner}/{repo},給綁了存取庫的那幾筆對出它綁的是哪一條路徑。 +REPOS="$TMPD/repos.txt" +REPOMAP="$TMPD/repomap.txt" +: >"$REPOS"; : >"$REPOMAP" +REPO_WHY='' +REPO_SCAN='-' +SCAN_SH="$HERE/scan-repos.sh" +[ -f "$SCAN_SH" ] || SCAN_SH=$(find_tool assist tools/scan-repos.sh) || SCAN_SH='' +if [ -z "$SCAN_SH" ]; then + REPO_WHY='找不到 scan-repos.sh,{repo} 沒有代入來源' + REPO_SCAN='missing' +else + "$SCAN_SH" list --root "$ROOT" >"$TMPD/scan.out" 2>"$TMPD/scan.err" + _src=$? + REPO_SCAN="rc$_src" + case "$_src" in + # 0 是全部都對得出盤點頁頁名,1 是有幾個對不出來或路徑被拒——後者交出來的那幾條照用, + # 對不出頁名不影響唯讀盤點跑得動。其餘各碼一律當成沒有來源,並把它印的理由帶上。 + 0|1) + sed -n 's/^repo=\([^ ]*\) slug=\([^ ]*\).*/\1'"$TAB"'\2/p' "$TMPD/scan.out" >"$REPOMAP" + cut -f1 "$REPOMAP" >"$REPOS" ;; + *) REPO_WHY=$(sed -n 's/^\[jsc\]\[助理掃描\]\[ERR\]://p' "$TMPD/scan.err" 2>/dev/null | clip 200) + [ -n "$REPO_WHY" ] || REPO_WHY="存取庫掃描回結束碼 $_src,{repo} 代不出目標" ;; + esac + # 掃描那一支的逐項判定行被導進暫存檔,所以外面看不到。原樣轉出來:被拒的那幾個存取庫是 + # 這一輪「掃到但沒交出去」的名單,那份名單不能只留在暫存檔裡。 + sed -n 's/^\(rejected=\|unnamed=\|excluded=\|skipped_link=\|not_a_repo=\)/repo_scan_note=\1/p' \ + "$TMPD/scan.out" "$TMPD/scan.err" 2>/dev/null +fi +N_REPOS=$(awk 'END{print NR+0}' "$REPOS") +if [ -z "$REPO_WHY" ] && [ "$N_REPOS" -eq 0 ]; then + REPO_WHY='存取庫掃描一個都沒掃到,{repo} 代不出目標' +fi + +# 這一筆綁的存取庫對出一條掃到的路徑。綁的值可以是工作目錄路徑,也可以是 {owner}/{repo}—— +# 待辦簿的欄位說明只寫「這一筆綁哪一個存取庫」,兩種寫法都有人會填,所以兩種都認。 +# 對不出來一律回失敗,不退回「全部存取庫」:那一筆指名了一個目標,代成全部就是跑了 +# 十九個沒有人要它跑的地方。 +repo_path_of() { # $1=綁定值 + awk -F"$TAB" -v want="$1" '$1==want || $2==want {print $1; found=1; exit} END{exit !found}' "$REPOMAP" +} # --- 判斷動作是哪一種 --- @@ -221,6 +278,25 @@ cmd_shape_ok() { # $1=指令 return 0 } +# 把一個代入點換成清單裡的每一個值,一個值一行。清單是空的又剛好命中代入點,那一行就 +# 整行消失——所以呼叫端要先確定清單非空,別靠這裡回錯:目標數變成 0 的那一筆會被當成 +# 「沒有東西要跑」而算成功,而它其實一次都沒跑。 +expand_over() { # $1=代入點 $2=值清單檔 $3=輸入檔 $4=輸出檔 + : >"$4" 2>/dev/null || return 1 + while IFS= read -r _line; do + [ -n "$_line" ] || continue + case "$_line" in + *"$1"*) + while IFS= read -r _v; do + [ -n "$_v" ] || continue + printf '%s\n' "$(printf '%s' "$_line" | sed "s|$1|$_v|g")" >>"$4" + done <"$2" ;; + *) printf '%s\n' "$_line" >>"$4" ;; + esac + done <"$3" + return 0 +} + # --- 逐筆處理 --- N_DUE=0; N_RUN=0; N_OK=0; N_FAIL=0; N_HELD=0; N_SKIP=0; N_WRITE_BAD=0 @@ -272,23 +348,42 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu _targets="$TMPD/targets.$c_id" : >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。" _hold='' + # 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。 + _repovals="$REPOS" case "$c_action" in *'{repo}'*) - [ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{repo} 代不出目標' ;; + if [ -n "$REPO_WHY" ]; then + _hold="$REPO_WHY" + else + _bind=$(sed -n 's/^repo=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1) + if [ -n "$_bind" ]; then + if _bp=$(repo_path_of "$_bind"); then + _repovals="$TMPD/repoval.$c_id" + printf '%s\n' "$_bp" >"$_repovals" 2>/dev/null || die 5 "暫存檔寫不進去:$_repovals。" + else + _hold="這一筆綁的存取庫「$_bind」這一輪沒掃到,代不出目標" + fi + fi + fi ;; + esac + case "$c_action" in + *'{cli}'*) [ "$N_CLI" -gt 0 ] || _hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' ;; esac if [ -z "$_hold" ]; then - case "$c_action" in - *'{cli}'*) - if [ "$N_CLI" -eq 0 ]; then - _hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' - else - while IFS= read -r _c; do - [ -n "$_c" ] || continue - printf '%s\n' "$(printf '%s' "$c_action" | sed "s|{cli}|$_c|g")" >>"$_targets" - done <"$CLIS" - fi ;; - *) printf '%s\n' "$c_action" >>"$_targets" ;; - esac + # 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {repo} 與 {cli} 時,目標數是兩邊 + # 的乘積——三個存取庫乘五支 CLI 就是十五個目標,一個目標跑一次。 + printf '%s\n' "$c_action" >"$TMPD/x0.$c_id" + expand_over '{repo}' "$_repovals" "$TMPD/x0.$c_id" "$TMPD/x1.$c_id" \ + || die 5 "暫存檔寫不進去:$TMPD/x1.$c_id。" + expand_over '{cli}' "$CLIS" "$TMPD/x1.$c_id" "$_targets" \ + || die 5 "暫存檔寫不進去:$_targets。" + # 代入之後再驗一次禁止字元。種入那一支與上面的 cmd_shape_ok 驗的是還沒代入的字面, + # 代進去的值是這一輪現場算出來的——路徑與 CLI 代號都有可能帶進新的字元,而帶進來的 + # 那一刻就是送進殼的前一刻。 + if grep -q '[$`;|&~]' "$_targets" 2>/dev/null; then + _hold='代入之後出現金錢符號、反引號、分號、管線、連接符號或波浪號,代入來源有問題' + : >"$_targets" + fi fi if [ -n "$_hold" ]; then N_HELD=$((N_HELD + 1)) @@ -323,9 +418,9 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t" else _entry_rc=1 - [ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | head -n1 | cut -c1-160) + [ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | clip 160) printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \ - "$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | head -n1 | cut -c1-160)" + "$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | clip 160)" fi done <"$_targets" @@ -362,10 +457,13 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu fi done <"$ROWS" -printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s\n' \ +printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s repos=%s repo_scan=%s\n' \ "$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \ - "$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}" + "$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}" \ + "$N_REPOS" "$REPO_SCAN" +[ -n "$REPO_WHY" ] && note "存取庫清單這一輪是空的:$REPO_WHY。帶 {repo} 代入點的那幾筆全部跳過,逐筆印在上面的 held= 那幾行。" +[ "$REPO_SCAN" = 'rc1' ] && note "存取庫掃描回 1:掃到的存取庫裡有幾個對不出 REPO_{HASH} 盤點頁頁名,或路徑帶了空白與殼層特殊字元而被拒。交出來的那幾條照用,被拒的那幾個這一輪沒有被盤點到——理由逐個印在掃描那一支的 rejected= 與 unnamed= 那幾行。" [ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 CLI 反覆消失就要考慮把它裝到穩定位置。" [ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。" [ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。" diff --git a/tools/scan-repos.sh b/tools/scan-repos.sh new file mode 100755 index 0000000..3cae74b --- /dev/null +++ b/tools/scan-repos.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env sh +# scan-repos.sh — 掃出工作目錄底下的存取庫,交出 {repo} 代入點要用的那份清單。 +# +# 用法: +# scan-repos.sh list [--scan-root {字面絕對路徑}] [--depth {0..3}] [--root {字面絕對根目錄}] +# scan-repos.sh paths [同上] +# +# list 一行一個存取庫,帶工作目錄、{owner}/{repo} 與對應的 REPO_{HASH} 頁名 +# paths 只印工作目錄,一行一條。給 {repo} 代入點直接讀 +# +# 環境變數: +# JSC_ASSIST_SCAN_ROOT 掃描起點。--scan-root 優先於它 +# JSC_ASSIST_SCAN_EXCLUDE 排除清單,空白分隔,比對目錄名(不是整條路徑),可用萬用字元 +# JSC_ASSIST_HASH_ID hash-id 路徑覆寫;找不到並排存取庫時才設 +# +# 結束碼: +# 0 掃到至少一個存取庫,而且每一個都對得出 REPO_{HASH}、路徑也都送得進殼 +# 1 掃到了,但有幾個對不出頁名或路徑被拒。可用的那幾個照印,被拒的逐行印在 rejected= +# 有 .git 卻不被 git 認的那幾個也算在這一碼裡,印在 not_a_repo= +# 2 用法錯誤:不認得的子命令或選項、選項缺值、--scan-root 不是絕對路徑、--depth 超出範圍 +# 3 掃描起點沒設定,或那條路徑不是存在的目錄——**什麼都沒掃**,不等於「這台機器沒有存取庫」 +# 4 掃過了,一個 git 存取庫都沒有 +# +# --- 為什麼掃描起點不給預設值 --- +# +# 這份清單的用途是把存取庫路徑代進待辦簿的指令,然後在無人值守那一輪送進殼執行。 +# 猜錯掃描起點的後果不是掃不到東西,是**在猜錯的那些目錄底下跑指令**,而那一輪沒有人 +# 看得到它跑到哪裡去了。往上猜一層就從工作目錄變成家目錄,再往上就是整台機器。 +# 所以沒設就回結束碼 3 並說清楚要設哪一個變數,不退回任何一條路徑。 +# 排程那一輪的值由 schedule.sh install 當下從殼裡快照進條目,跟 wiki 存取庫那幾個變數同一條路。 +# +# --- 為什麼預設只掃一層 --- +# +# 工作目錄的版面是「一個存取庫一個子目錄」,一層就夠。往下遞歸會踩到三種東西: +# 存取庫自己的 node_modules 與 vendor 底下的第三方存取庫、封存目錄裡的舊版存取庫、 +# 還有 worktree 的複本。三種都會被當成現役存取庫盤點,而盤點結果讀起來完全正常。 +# --depth 最多收到 3:真的有嵌套版面時夠用,而再深就不是「工作目錄那一層」了, +# 那種版面該把掃描起點指到那一層,不是把界線調鬆。 +# +# --- 三種東西一定跳過,不看排除清單 --- +# +# 一、名稱以點開頭的目錄。快取、封存、暫存都藏在那裡,掃進去會把封存版當現役版盤點。 +# 二、符號連結。連結會把掃描帶出掃描起點,而那條界線一旦被繞過就沒有第二道。 +# 三、路徑裡有空白或殼層特殊字元的存取庫。執行那一支是用 `sh -c` 送出指令的,那條路徑 +# 會被殼再解一次——帶空白的路徑會被切成兩個參數,帶金錢符號的會被展開成別的東西。 +# 這種一律列進 rejected= 不交出去,不是靜靜跳過:那是一個真的存在的存取庫, +# 它沒有被盤點到這件事要有人知道。 +set -u + +usage() { + echo 'usage: scan-repos.sh {list|paths} [--scan-root 絕對路徑] [--depth 0..3] [--root 絕對路徑]' >&2 + exit 2 +} + +die() { _c=$1; shift; printf '[jsc][助理掃描][ERR]:%s\n' "$*" >&2; exit "$_c"; } +# 逐項的判定行與收尾統計。paths 那個模式的標準輸出要是純路徑——呼叫端拿它當代入點來源, +# 混一行 unnamed= 進去就會被代成一條不存在的路徑然後送進殼。所以那個模式一律改走標準錯誤。 +emit() { if [ "$MODE" = 'paths' ]; then printf '%s\n' "$*" >&2; else printf '%s\n' "$*"; fi; } +note() { printf '[jsc][助理掃描]:%s\n' "$*" >&2; } +warn() { printf '[jsc][助理掃描][WARN]:%s\n' "$*" >&2; } + +[ "$#" -ge 1 ] || usage +MODE=$1; shift +case "$MODE" in + list|paths) ;; + *) usage ;; +esac + +SCAN_ROOT='' +OPT_ROOT='' +DEPTH=1 +while [ "$#" -gt 0 ]; do + case "$1" in + --scan-root) [ "$#" -ge 2 ] || usage; SCAN_ROOT=$2; shift 2 ;; + --root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;; + --depth) [ "$#" -ge 2 ] || usage; DEPTH=$2; shift 2 ;; + *) usage ;; + esac +done + +case "$DEPTH" in + 0|1|2|3) ;; + *) die 2 "--depth 只收 0 到 3,收到的是「$DEPTH」。0 只看掃描起點自己,1 是它底下那一層。要掃更深的版面就把掃描起點指到那一層。" ;; +esac + +case "$OPT_ROOT" in + ''|/*) ;; + *) die 2 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;; +esac + +[ -n "$SCAN_ROOT" ] || SCAN_ROOT="${JSC_ASSIST_SCAN_ROOT:-}" +if [ -z "$SCAN_ROOT" ]; then + die 3 '掃描起點沒設定。這一支不猜:猜錯的後果是在猜錯的那些目錄底下跑指令,而無人值守那一輪沒有人看得到它跑到哪裡去了。請帶 --scan-root,或在殼裡設 JSC_ASSIST_SCAN_ROOT 之後重跑一次 schedule.sh install patrol,把值快照進排程條目。' +fi +case "$SCAN_ROOT" in + /*) ;; + *) die 2 "掃描起點要是字面絕對路徑,收到的是「$SCAN_ROOT」。權限層比對的是還沒展開的指令字面,帶變數或波浪號的路徑進不了允許清單。" ;; +esac +SCAN_ROOT=${SCAN_ROOT%/} +# 去掉尾斜線之後空字串就是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄, +# 那不是設定錯誤就是打錯字,兩種都不該照著跑。 +[ -n "$SCAN_ROOT" ] || die 2 '掃描起點是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,這一支不接。' +[ -d "$SCAN_ROOT" ] || die 3 "掃描起點不是存在的目錄:$SCAN_ROOT。什麼都沒掃,跟「這台機器沒有存取庫」不是同一件事——後者要回 4。" + +TAB=$(printf '\t') +TMPD=$(mktemp -d 2>/dev/null) || die 3 '暫存目錄建不起來。' +trap 'rm -rf "$TMPD"' EXIT + +# --- 找 hash-id --- + +HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P) +ROOT="$OPT_ROOT" +[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT='' + +hash_id_sh() { + if [ -n "${JSC_ASSIST_HASH_ID:-}" ] && [ -f "$JSC_ASSIST_HASH_ID" ]; then + printf '%s' "$JSC_ASSIST_HASH_ID"; return 0 + fi + for _d in "jsc-gitea" "gitea"; do + [ -n "$ROOT" ] && [ -f "$ROOT/$_d/tools/hash-id" ] && { printf '%s' "$ROOT/$_d/tools/hash-id"; return 0; } + done + return 1 +} +HASH_ID=$(hash_id_sh) || HASH_ID='' + +# 頁名的雜湊規則跟 jsc-sdlc 盤點頁是同一條:對那個存取庫自己的 {owner}/{repo} 取完整四十碼 +# 大寫 SHA-1。共用那一支找不到就退回本機的 sha1sum——同一條規則、同一個值,只是少一層共用。 +# 兩邊都算不出來就不硬湊一個頁名:湊出來的頁名指向沒有人讀的那一頁。 +# +# 用哪一種先決定好,不在每一筆裡各判一次。算雜湊那一段跑在命令替換裡,也就是子殼裡, +# 在那裡設「這一輪用了退路」的旗標,回到外面就沒了——那句提示會永遠印不出來。 +HASH_MODE='none' +if [ -n "$HASH_ID" ] && [ -n "$(printf '%s' probe | "$HASH_ID" 2>/dev/null)" ]; then + HASH_MODE='shared' +elif command -v sha1sum >/dev/null 2>&1; then + HASH_MODE='sha1sum' +elif command -v shasum >/dev/null 2>&1; then + HASH_MODE='shasum' +fi +hash40() { # $1=要算的字串 + case "$HASH_MODE" in + shared) printf '%s' "$1" | "$HASH_ID" 2>/dev/null ;; + sha1sum) printf '%s' "$1" | sha1sum | awk '{print toupper($1)}' ;; + shasum) printf '%s' "$1" | shasum -a 1 | awk '{print toupper($1)}' ;; + *) return 1 ;; + esac +} + +# --- 找候選目錄 --- + +# 排除清單比對目錄名,不比對整條路徑:整條路徑會隨掃描起點變,同一份清單換一台機器就失效。 +EXCLUDED_NAMES="${JSC_ASSIST_SCAN_EXCLUDE:-}" +excluded() { # $1=目錄名 + [ -n "$EXCLUDED_NAMES" ] || return 1 + for _pat in $EXCLUDED_NAMES; do + # shellcheck disable=SC2254 + case "$1" in + $_pat) return 0 ;; + esac + done + return 1 +} + +CAND="$TMPD/cand.txt" +: >"$CAND" +LINKS="$TMPD/links.txt" +: >"$LINKS" + +# find 預設不跟符號連結走,所以連結目錄不會出現在 -type d 的結果裡。這裡另外撈一次 -type l +# 是為了把它們印出來:一個被跳過的連結跟一個不存在的目錄,在報告上看起來不能是同一件事。 +# +# 排除清單不寫進 find 的剪枝條件,改在下面逐個判定時濾掉。理由是剪掉的目錄就不會出現在 +# 結果裡,也就報不出「這個被排除了」——而一份沒說自己排除了什麼的清單,讀起來跟沒有排除 +# 清單一樣。代價是深度兩層以上時會走進被排除的目錄裡再一層,而深度上限是 3,所以最多一層。 +if [ "$DEPTH" -ge 1 ]; then + find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \ + -name '.*' -prune -o -type l -print 2>/dev/null >"$LINKS" + find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \ + -name '.*' -prune -o -type d -print 2>/dev/null >"$CAND" +fi +# depth 0 與更深的版面都要把掃描起點自己算進來:工作目錄本身也可能就是一個存取庫。 +printf '%s\n' "$SCAN_ROOT" >>"$CAND" +sort -u -o "$CAND" "$CAND" + +# --- 逐個判定 --- + +N_REPO=0; N_REJ=0; N_UNNAMED=0; N_EXCL=0; N_LINK=0; N_FAKE=0 +OUT="$TMPD/out.txt" +: >"$OUT" + +while IFS= read -r d; do + [ -n "$d" ] || continue + _name=$(basename -- "$d") + if excluded "$_name"; then + N_EXCL=$((N_EXCL + 1)) + emit "excluded=$d reason=在排除清單上" + continue + fi + # .git 可能是目錄(一般存取庫),也可能是檔案(worktree 與子模組),所以先看它在不在, + # 但**在不在只是初篩,不是判定**。 + [ -e "$d/.git" ] || continue + + # 判定交給 git 自己。有 .git 不等於是存取庫:實測這台機器上有一個目錄底下的 .git 只剩 + # 一個空的 info/,git 一句「not a git repository」,而只看 .git 在不在的掃描把它算成 + # 一個存取庫、印成「沒有 origin」——那個說法讀起來像「這個存取庫還沒接遠端」, + # 不像「這裡根本不是存取庫」。兩件事的處置完全不同。 + # + # 只問「是不是在工作樹裡」不夠:git 會從這個目錄往上找,$d/.git 壞掉時它會找到上一層的 + # 存取庫然後回答「是」。所以要再核對它認定的頂層就是 $d 自己。兩邊都解成實體路徑再比, + # 掃描起點帶符號連結那一段時字串才對得上。 + _top=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null) || _top='' + _dreal=$(CDPATH= cd -P -- "$d" 2>/dev/null && pwd -P) || _dreal='' + _topreal='' + [ -n "$_top" ] && { _topreal=$(CDPATH= cd -P -- "$_top" 2>/dev/null && pwd -P) || _topreal=''; } + if [ -z "$_topreal" ] || [ "$_topreal" != "$_dreal" ]; then + N_FAKE=$((N_FAKE + 1)) + if [ -z "$_top" ]; then + emit "not_a_repo=$d reason=有 .git 但 git 自己不認,多半是刪剩的殘骸" + else + emit "not_a_repo=$d reason=有 .git 但 git 認定的頂層是 $_top,這裡不是存取庫的根" + fi + continue + fi + + case "$d" in + *[!A-Za-z0-9/._-]*) + N_REJ=$((N_REJ + 1)) + emit "rejected=$d reason=路徑裡有空白或殼層特殊字元,送進 sh -c 會被再解一次" + continue ;; + esac + + _url=$(git -C "$d" remote get-url origin 2>/dev/null) || _url='' + _slug='' + if [ -n "$_url" ]; then + # 兩種形狀:git@host:owner/repo.git 與 https://host/owner/repo.git。都只取最後兩段。 + _s=${_url%.git} + _s=${_s#*://} + _s=${_s#*@} + case "$_s" in + *:*) _s=${_s#*:} ;; + esac + _s=${_s#/} + _owner='' + _repo=${_s##*/} + _rest=${_s%/*} + [ "$_rest" != "$_s" ] && _owner=${_rest##*/} + [ -n "$_owner" ] && [ -n "$_repo" ] && _slug="$_owner/$_repo" + fi + + _page='-' + if [ -n "$_slug" ]; then + _h=$(hash40 "$_slug") || _h='' + [ -n "$_h" ] && _page="REPO_$_h" + fi + if [ "$_page" = '-' ]; then + N_UNNAMED=$((N_UNNAMED + 1)) + if [ -z "$_slug" ]; then + emit "unnamed=$d reason=沒有 origin 遠端,或網址對不出 {owner}/{repo},算不出盤點頁頁名" + else + emit "unnamed=$d slug=$_slug reason=這台機器算不出 SHA-1,算不出盤點頁頁名" + fi + fi + + N_REPO=$((N_REPO + 1)) + printf '%s%s%s%s%s\n' "$d" "$TAB" "${_slug:--}" "$TAB" "$_page" >>"$OUT" +done <"$CAND" + +while IFS= read -r l; do + [ -n "$l" ] || continue + N_LINK=$((N_LINK + 1)) + emit "skipped_link=$l reason=符號連結,跟著走會把掃描帶出掃描起點" +done <"$LINKS" + +if [ "$MODE" = 'paths' ]; then + cut -f1 "$OUT" +else + while IFS="$TAB" read -r p s g; do + printf 'repo=%s slug=%s page=%s\n' "$p" "$s" "$g" + done <"$OUT" +fi + +emit "scan_root=$SCAN_ROOT depth=$DEPTH repos=$N_REPO unnamed=$N_UNNAMED rejected=$N_REJ excluded=$N_EXCL skipped_links=$N_LINK not_a_repo=$N_FAKE" + +case "$HASH_MODE" in + sha1sum|shasum) note '找不到共用那一支 hash-id(jsc-gitea 的 tools/hash-id),這一輪的頁名改用本機的 SHA-1 算。兩者是同一條規則、值相同,只是少了一層共用;--root 餵對就會用共用那一支。' ;; + none) warn '這台機器既沒有共用那一支 hash-id,也沒有 sha1sum 與 shasum,這一輪一個盤點頁頁名都算不出來。存取庫路徑照樣交出去代 {repo},唯讀盤點跑得動;盤點結果沒有頁可以寫回去。' ;; +esac +[ "$N_REJ" -gt 0 ] && warn "有 $N_REJ 個存取庫的路徑送不進殼,這一份清單裡沒有它們,逐個印在上面的 rejected= 那幾行。那幾個是真的存在的存取庫,只是路徑帶了空白或殼層特殊字元——要盤點得到就得換路徑。" +[ "$N_UNNAMED" -gt 0 ] && warn "有 $N_UNNAMED 個存取庫對不出 REPO_{HASH} 盤點頁頁名,逐個印在上面的 unnamed= 那幾行。它們的路徑照樣交出去代 {repo},唯讀盤點跑得動;但盤點結果沒有頁可以寫回去。" + +if [ "$N_REPO" -eq 0 ]; then + die 4 "掃過 $SCAN_ROOT 底下 $DEPTH 層,一個 git 存取庫都沒有。掃描起點指錯一層就會長這樣——工作目錄的版面是「一個存取庫一個子目錄」,指到某一個存取庫裡面就只剩它自己那一個,指到家目錄就一個都沒有。" +fi +[ "$N_FAKE" -gt 0 ] && warn "有 $N_FAKE 個目錄底下有 .git 卻不被 git 認,逐個印在上面的 not_a_repo= 那幾行。那不是「還沒接遠端的存取庫」,是刪剩的殘骸或者不是存取庫的根——只看 .git 在不在的掃描會把它算成一個存取庫。" + +if [ "$N_REJ" -gt 0 ] || [ "$N_UNNAMED" -gt 0 ] || [ "$N_FAKE" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/tools/schedule.sh b/tools/schedule.sh index 941eb49..7cc5d59 100755 --- a/tools/schedule.sh +++ b/tools/schedule.sh @@ -125,6 +125,9 @@ # JSC_WIKI_REPO_{TYPE} 各頁型的 wiki 存取庫。已設定的全部快照進條目。名單是當下從 # 環境撈出來的,不寫死,所以新增頁型自動涵蓋,這支不必跟著改。 # 目錄頁那一支專用變數也在裡面 +# JSC_ASSIST_SCAN_ROOT 存取庫掃描的起點。install 當下快照進條目;沒有它,帶 {repo} +# 代入點的內建項每一輪都代不出目標 +# JSC_ASSIST_SCAN_EXCLUDE 存取庫掃描的排除清單。install 當下快照進條目 set -u MARK_PREFIX='# jsc-assist:assistant' @@ -133,6 +136,7 @@ STATE_DIR="$JSC_HOME/assistant" CURRENT="$JSC_HOME/current" LOG="$STATE_DIR/schedule.log" CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}" +CRON_LINE_MAX="${JSC_ASSIST_CRON_LINE_MAX:-1000}" TASK_PREFIX='jsc-assist-assistant' DRYRUN=0 @@ -344,7 +348,12 @@ snapshot_names() { # 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己 # 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份 # 路徑清單,而清單會過期。 - printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL + # 掃描起點與排除清單也要快照。存取庫掃描那一支刻意不猜掃描起點,沒有值就整個代入點 + # 代不出來,帶 {repo} 的內建項每一輪都會被跳過——而那一輪只會印一行 held=,看起來像 + # 「這一批還沒接上」,不像「有一個變數沒進條目」。排除清單少了同樣要命:殼裡排除掉的 + # 那幾個目錄,在排程那一輪會被當成要盤點的存取庫。 + printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL \ + JSC_ASSIST_SCAN_ROOT JSC_ASSIST_SCAN_EXCLUDE env 2>/dev/null \ | sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \ | sort -u @@ -708,6 +717,25 @@ crontab_install() { printf '\n' >>"$_new" done + # 條目長度先量過再往下走,兩個模式都量。 + # + # cron 的一行有長度上限,超過就整批寫不進去,而錯誤訊息是 crontab 自己吐的一句 + # 「command too long」。實測踩過:把整條 PATH 快照進條目之後 install 回 4——而同一組參數的 + # --dry-run 全綠,因為那一路根本不碰 crontab。一道只在真的寫入時才會發現的限制,等於 + # 沒有預檢。 + # 上限取 1000:那是 vixie-cron 的 MAX_COMMAND,不是這台機器上量出來的,所以留一個變數 + # 可以覆寫。這台機器實測 810 字元的條目裝得進去,加上整條 PATH 的那一次裝不進去。 + for _job in $JOBS; do + _len=$(cron_entry "$_job" | wc -c | tr -d ' ') + printf 'entry_len=%s job=%s limit=%s\n' "$_len" "$_job" "$CRON_LINE_MAX" + if [ "$_len" -ge "$CRON_LINE_MAX" ]; then + die 4 "$_job 的條目有 $_len 個位元組,達到 cron 一行的上限 $CRON_LINE_MAX,這一批裝不進去。條目長度的來源是環境變數快照($SNAPSHOT_NAMES)與 PATH 快照——先看哪一個值特別長,多半是掃描起點或排除清單寫得太長。縮短那個值,或用 JSC_ASSIST_CRON_LINE_MAX 覆寫上限(覆寫之前先確認這台機器的 cron 真的收得下)。" + fi + if [ "$_len" -ge $((CRON_LINE_MAX - 100)) ]; then + warn "$_job 的條目有 $_len 個位元組,距離 cron 一行的上限 $CRON_LINE_MAX 不到 100。再多加一個環境變數快照就會裝不進去,而那一次的錯誤訊息只會說 command too long。" + fi + done + if [ "$DRYRUN" -eq 1 ]; then for _job in $JOBS; do printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)"