diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 356f465..28910bd 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.3.1", + "version": "0.3.2", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index cba128b..f90d119 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.3.1", + "version": "0.3.2", "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 0782a74..584662a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.3.1", + "version": "0.3.2", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { diff --git a/references/behaviors.md b/references/behaviors.md index 3edc2cc..92e5194 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` 那一路一律不問。被權限層擋下的指令,回報一律帶完整指令列原文(含環境變數前綴)並指出是哪一步送出的:擋下不是工具回的錯誤,沒有結束碼、沒有 stderr、工具自己的日誌上也沒有痕跡,指令字面是唯一的證據,也是唯一能拿去對允許清單的東西——而清單比對的是還沒展開的指令字面。實測踩過一次:某一輪只寫「寫入被核准機制擋下」,沒說是哪一支工具、哪一條指令,事後把明顯的嫌疑一個一個排除掉,真正那一條始終沒找到。不參與閘門判定 | +| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{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=` 逐行原樣轉出成 `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 e09e10b..52fde79 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 `&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=\)/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..cb50bf0 --- /dev/null +++ b/tools/scan-repos.sh @@ -0,0 +1,273 @@ +#!/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= +# 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 +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 + + 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" + +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 +if [ "$N_REJ" -gt 0 ] || [ "$N_UNNAMED" -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)"