From 465bd9d40abad82f9df2ef36e85abead4ef34c0d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 7 Sep 2026 10:53:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=E9=80=BE=E6=9C=9F=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E6=8E=A5=E4=B8=8A=E4=BE=86=EF=BC=8C=E9=80=BE=E6=9C=9F?= =?UTF-8?q?=E8=88=87=E9=80=A3=E7=BA=8C=E5=A4=B1=E6=95=97=E6=AF=8F=E4=B8=80?= =?UTF-8?q?=E8=BC=AA=E9=83=BD=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逾期本來沒實作,監控頁上就寫著「上面的判定只講到期,不講逾期」。 到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個 截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期; 一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。 合成一個值就把兩種情況混成同一格。 due.sh 多第十二欄 overdue,值是減號(沒有截止時間)、no、逾期的秒數, 或 bad。bad 算成欄位不合法回 2——一個解不開的截止時間跟沒有截止時間 在畫面上長得一模一樣,而前者是登錄的時候就填錯了。done 那幾筆不算逾期; 被暫停的那幾筆照算,因為暫停中止的是到期判定,不是日曆,而一筆被暫停 又逾期的交辦正好是最容易被忘掉的那一種。 另加唯讀子命令 due.sh overdue 給狀態查詢那一路走。走 scan 會推進快照, 那一輪之後的事件判定會全部落空;走 events 又判不到逾期;讓呼叫端自己 拿 due 欄去比更糟,那是第二套判定,漂移的那一天兩邊都說自己對。 patrol.sh 在逾期或連續失敗筆數大於 0 時各列一筆待人處理,每一輪都列。 兩種都不會自己好:截止時間過了不會因為過更久就不逾期,連續失敗的項目 每一輪都會再試一次然後再失敗一次。「提過了」不是「處理過了」,而助理 不替人按暫停,那個狀態留給人設。 監控頁那一節逐筆點名連續失敗的那幾筆——id、失敗幾次、最後一次執行、 標題。原本只印一個總數,而失敗次數這個欄位存在的理由是指出「有一筆壞掉 的項目每輪重試而沒人知道」,不說出是哪一筆,那個理由只完成一半。 目錄頁刻意不加欄位。那一頁一台機器一個區塊、各自那一輪寫,新加一條只有 跑到新版的機器寫得出來,讀的人分不出「這台沒有逾期」與「這台還沒寫這 一條」——理由與當初把「本輪非 ok 事件數」擋在那一頁外面的一樣。 欄位數從 11 變 12,run-due.sh 那道欄位數檢核跟著改成 12。那道檢核擋的 不是第 12 欄本身,是「兩支不是同一版」——版本不同的時候,前 11 欄的 順序也不能假設還是原來那樣。 Co-Authored-By: Claude Opus 5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- references/behaviors.md | 4 +- skills/assistant/SKILL.md | 6 +- tools/due.sh | 185 ++++++++++++++++++++++++++++++++----- tools/patrol.sh | 45 ++++++++- tools/run-due.sh | 6 +- 8 files changed, 216 insertions(+), 36 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 55aed5a..e3e6adb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.3.3", + "version": "0.3.4", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4c8d770..32f2d4d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.3.3", + "version": "0.3.4", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills", "jsc": { diff --git a/plugin.json b/plugin.json index 5d6e02a..0b2375c 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-assist", - "version": "0.3.3", + "version": "0.3.4", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "skills": "./skills/", "jsc": { diff --git a/references/behaviors.md b/references/behaviors.md index 124c590..1f97ee3 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_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、工具自己的日誌上也沒有痕跡,指令字面是唯一的證據,也是唯一能拿去對允許清單的東西——而清單比對的是還沒展開的指令字面。實測踩過一次:某一輪只寫「寫入被核准機制擋下」,沒說是哪一支工具、哪一條指令,事後把明顯的嫌疑一個一個排除掉,真正那一條始終沒找到。不參與閘門判定 | +| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{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`;`collect` 另外印 `tasks_overdue=`,並在逾期或連續失敗筆數大於 0 時各列一筆待人處理,**每一輪都列,不因為上一輪提過就這一輪不提**——兩種都不會自己好,而「提過了」不是「處理過了」;監控頁那一節逐筆點名連續失敗的那幾筆(id、已連續失敗幾次、最後一次執行、標題),不只印一個總數,因為失敗次數這個欄位存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」,不說是哪一筆那個理由只完成一半;目錄頁不為這兩件事加欄位,理由同「本輪非 ok 事件數」那一條:那一頁一台機器一個區塊、各自那一輪寫,新加一條只有跑到新版的機器寫得出來,讀的人分不出「這台沒有逾期」與「這台還沒寫這一條」;`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`、`overdue`、`next`:`scan` 一輪一次,比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期與逾期;`events` 是唯讀預覽,一律不推進快照;`overdue` 只判逾期、也一律唯讀,狀態查詢那一路走這一個(走 `scan` 會推進快照,那一輪之後的事件判定會全部落空;走 `events` 又判不到逾期;讓呼叫端自己拿 `due` 欄去比更糟,那是第二套判定,漂移的那一天兩邊都說自己對);`next` 是純算,給一組欄位算出 `next_run`。到期與逾期分兩欄不合成一個判定:到期是「該跑了」,逾期是「`due` 欄那個截止時間過了」,一筆七天一次的檢查項天天都可能到期卻永遠不會逾期,一筆有截止時間的交辦可能早就逾期卻因為排定點還沒到而判成還沒到;`overdue` 那一欄的值是減號(沒有截止時間)、`no`、逾期的秒數,或 `bad`(`due` 欄解不開,算成欄位不合法回 2——一個解不開的截止時間跟沒有截止時間在畫面上長得一模一樣);`done` 那幾筆不算逾期,被暫停的那幾筆照算(暫停中止的是到期判定,不是日曆,而一筆被暫停又逾期的交辦正好是最容易被忘掉的那一種)。七個結束碼各有處置: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`,不落在任何存取庫 | +| 可驗證跡象 | 四個操作的轉錄裡,每一條指令列都是字面絕對路徑,開頭是 `/`,沒有 `$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 013555d..b3cbf1c 100644 --- a/skills/assistant/SKILL.md +++ b/skills/assistant/SKILL.md @@ -348,7 +348,9 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by One round: read five sources, record the result, then beat. Everything before the heartbeat is read-only except the round's own scratch files. Ask nobody anything. -1. **Collect.** Run `{CURRENT}/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). That is the same split step 0 branched on: 排程 is the unattended round that read its root out of the invocation text, 手動 the round somebody asked for. Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=`, `contents_file=` and `due_rows_file=` — that last one is what step 5 has to be given, and an empty value there means the judging step produced no list, so step 5 has nothing to act on and says so rather than falling back to anything. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped. +1. **Collect.** Run `{CURRENT}/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). That is the same split step 0 branched on: 排程 is the unattended round that read its root out of the invocation text, 手動 the round somebody asked for. Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, `tasks_total=`, `tasks_failing=`, `tasks_due=`, `tasks_overdue=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=`, `contents_file=` and `due_rows_file=` — that last one is what step 5 has to be given, and an empty value there means the judging step produced no list, so step 5 has nothing to act on and says so rather than falling back to anything. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped. + + **`tasks_overdue=` and `tasks_failing=` earn a 待人處理 row every round, and `collect` writes both of them itself.** Neither condition heals on its own: a deadline that has passed does not become un-passed, and an entry that fails retries next round and fails again. So the row is repeated every round rather than suppressed after the first — **"already reported" is not "already handled"**, and the assistant does not pause an entry on anybody's behalf; `paused` is a state a person sets and only a person clears. The overdue count comes from the judging step and is a dash when that step could not judge, which is not the same as zero. The monitor page carries the two named tables under 待辦簿到期與逾期: which entries are overdue and by how long, and which ones have been failing and how many times. **The directory page gains no field for either.** That page holds one block per machine, each written by that machine's own round, so a new field would only appear for machines already on the new version, and a reader could not tell "nothing overdue here" from "this machine has not written that field yet" — the same reason the 本輪非 ok 事件數 field was kept off it. **The status event lines come out of the same call.** `collect` drained the stream and rotated it (see 「The status event stream」 above), so record `events_total=`, `events_bad=`, `events_unpaired=`, `events_running=`, `events_rotated=` and `events_file=` alongside the rest, and read `item=D-11` for whether that source was readable at all. The 執行狀態事件 subsection of `latest_file` already carries the two detail tables — the non-`ok` events and the starts with no matching end — so never rebuild either by hand and never call `report-status.sh` yourself: a second `drain` this round would either return exit 3 or eat events that then reach no page at all. @@ -442,7 +444,7 @@ Read-only throughout. This operation creates, modifies and deletes nothing under Completion condition: every matching sentence is printed, or none of the four combinations applied. -6. **Flag the repeatedly failing tasks.** Append 已連續失敗 N 次 to every row whose `fail_count` is above 0, with `N` taken verbatim from the file. A broken entry that retries every round with nobody noticing is the reason this field exists, so let no such row leave the table unmarked. Completion condition: every row with `fail_count` above 0 carries the marker and its number matches the file. +6. **Flag the repeatedly failing and the overdue tasks.** Append 已連續失敗 N 次 to every row whose `fail_count` is above 0, with `N` taken verbatim from the file. A broken entry that retries every round with nobody noticing is the reason this field exists, so let no such row leave the table unmarked. Then run `{CURRENT}/jsc-assist/tools/due.sh overdue` and append 逾期 {human} to every row it names, with the wording taken from that tool's `human=` field. **Do not compare the `due` field against the clock yourself**: that would be a second judgement of the same thing, and the day the two disagree both will look right. That subcommand is read-only by construction — it advances no snapshot and compares no events, which is why `status` may call it while `scan` may not be called here at all. Exit 2 means at least one `due` field could not be parsed: report those as entries whose deadline was mistyped at registration, because **an unparseable deadline and no deadline look identical on screen**. A paused entry past its deadline is still overdue and is still named; being paused suspends the due judgement, not the calendar. Completion condition: every row with `fail_count` above 0 carries its marker, every row the overdue judgement named carries its own, and the numbers match what the two tools printed. 7. **Check the built-in items against the delegation list, read-only.** Run `{CURRENT}/jsc-assist/tools/seed-tasks.sh plan --root {CURRENT}`. `plan` writes nothing at all — it prints what a reconcile would do and stops — which is what makes it safe here, and `apply` must never be run from `status`. Judge the code by the seed-tasks.sh table and report the difference: the count of items the list expects but the task book lacks, the count of orphans the task book still holds, every `held=` row by name, every `drift=` row with the change the list asks for, every `pending=` row as a slice with an entry point still unwired — this is where a human finds out which reminders are waiting on plumbing rather than on them — and every `probe_bad=` row as an item that fell back to reminding. Say plainly that `start` is what applies any of it. On exit 1, 2 or 3 report that the comparison could not be made and why, and never present that as an aligned task book. Completion condition: the difference is reported with its counts and the held rows named, or the reason it could not be computed is reported, and nothing under `$JSC_HOME` was written. diff --git a/tools/due.sh b/tools/due.sh index 2893512..aa1998b 100755 --- a/tools/due.sh +++ b/tools/due.sh @@ -8,8 +8,9 @@ # [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}] # [--now {epoch 秒}] # -# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期。 -# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。 +# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期與逾期。 +# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。 +# overdue 只判逾期,唯讀:一份快照都不推進,一個事件都不比。狀態查詢那一路走這一個。 # next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。 # # 結束碼: @@ -17,7 +18,8 @@ # 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、 # 快照那一段原樣留著,呼叫端要把它標成警示 # 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、 -# --now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,其餘各筆照判 +# due 欄的截止時間解不開、--now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了, +# 其餘各筆照判 # 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批 # 事件會被判第二次。這一碼要吵出來,不能當成成功 # 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」 @@ -139,8 +141,13 @@ # # --- 這一支不判什麼 --- # -# 不判逾期(due 欄有沒有過),不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。 -# 它只回答兩個問題:本輪有哪些新事件、哪幾筆現在到期。做不做、怎麼做、失敗怎麼記,都在呼叫端。 +# 不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。 +# 它只回答三個問題:本輪有哪些新事件、哪幾筆現在到期、哪幾筆逾期了。做不做、怎麼做、失敗怎麼記, +# 都在呼叫端。 +# +# 到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個截止時間過了」。 +# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的交辦 +# 可能早就逾期,卻因為排定點還沒到而判成「還沒到」。合成一個值就會把這兩種情況混成同一格。 # # 環境變數: # JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 @@ -195,8 +202,9 @@ note() { printf '[jsc][助理到期判定]:%s\n' "$1" >&2; } usage() { cat >&2 <<'EOF' -usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run] - due.sh events [--now epoch 秒] +usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run] + due.sh events [--now epoch 秒] + due.sh overdue [--now epoch 秒] due.sh next --trigger at:...|after:... --recur once|every:間隔 [--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字] [--now epoch 秒] @@ -869,15 +877,51 @@ kv_get() { # $1=檔案 $2=鍵 # 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是 # recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位 # 字元也不會有換行,所以定位字元切得準。 -row_out() { # 十一個欄位,順序如上 - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ +row_out() { # 十二個欄位,順序如上 + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$(flat "$1")" "$(flat "$2")" "$(flat "$3")" "$(flat "$4")" "$(flat "$5")" \ "$(flat "$6")" "$(flat "$7")" "$(flat "$8")" "$(flat "$9")" \ - "$(flat "${10}")" "$(flat "${11}")" >>"$ROWS" + "$(flat "${10}")" "$(flat "${11}")" "$(flat "${12}")" >>"$ROWS" +} + +# 逾期判定。到期與逾期是兩件事:到期是「該跑了」,逾期是「截止時間過了」。 +# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的 +# 交辦事項可能早就逾期,卻因為排定點還沒到而判成「還沒到」。兩個判定各自獨立,一欄一個值。 +# +# 回傳三種值:due 欄是空的印減號、還沒過印 no、過了印逾期的秒數。 +# 印秒數不印 yes 的理由是「逾期一分鐘」與「逾期三個月」在報告上要看得出差別,而呼叫端 +# 只要判「是不是數字」就分得出有沒有逾期。 +# due 欄解不開的一律印 bad 並算成欄位不合法:一個解不開的截止時間跟沒有截止時間, +# 在畫面上長得一模一樣,而前者是登錄時就填錯了。 +# 逾期多久,給人看的寫法。機器可讀的那幾處一律留秒數:秒數比得出大小、也不必解析。 +# 頁面上只印秒數讀不出輕重——「528462 秒」要算過才知道是六天,而報告的用途正是讓人一眼 +# 分出「逾期一分鐘」與「逾期三個月」。 +dur_human() { # $1=秒數 + _ds=$(( $1 / 86400 )); _hs=$(( ($1 % 86400) / 3600 )); _ms=$(( ($1 % 3600) / 60 )) + if [ "$_ds" -gt 0 ]; then printf '%s 天 %s 小時' "$_ds" "$_hs" + elif [ "$_hs" -gt 0 ]; then printf '%s 小時 %s 分' "$_hs" "$_ms" + else printf '%s 分' "$_ms"; fi +} + +overdue_of() { # $1=due 欄的值;回傳值印在 stdout + case "$1" in + ''|'-') printf '%s' '-'; return 0 ;; + esac + _de=$(iso_to_epoch "$1") || _de='' + case "${_de:-}" in + ''|*[!0-9]*) printf '%s' 'bad'; return 1 ;; + esac + if [ "$NOW" -gt "$_de" ]; then + printf '%s' "$((NOW - _de))" + else + printf '%s' 'no' + fi + return 0 } scan_tasks() { T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0 + T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES='' true >"$ROWS" 2>/dev/null || { RC_FS=5; die 5 "逐筆判定的輸出檔寫不進去:$ROWS。"; } if [ ! -d "$TASKS_DIR" ]; then RC_EMPTY=4 @@ -898,11 +942,28 @@ scan_tasks() { _cr=$(kv_get "$_f" created) _lr=$(kv_get "$_f" last_run) _fc=$(kv_get "$_f" fail_count) + _du=$(kv_get "$_f" due) + + # 逾期照每一筆都判,連 paused 那幾筆也判。到期判定跳過 paused 是對的(那是人設的, + # 助理判它到期只會讓人以為它還在跑),但截止時間過了這件事不會因為有人按了暫停就消失 + # ——一筆被暫停又逾期的交辦,正好是最容易被忘掉的那一種。 + # done 那幾筆不算逾期:做完了就沒有截止時間可以過。 + _ov='-' + if [ "$_st" != done ]; then + _ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; } + case "$_ov" in + ''|*[!0-9]*) ;; + *) T_OVERDUE=$(( T_OVERDUE + 1 )) + OVERDUE_LINES="${OVERDUE_LINES}$(printf '%s\t%s\t%s\t%s\t%s' \ + "$(flat "$_id")" "$(flat "$_st")" "$(flat "$_du")" "$_ov" "$(flat "$_ti")") +" ;; + esac + fi if [ "$_st" != pending ]; then - # done 與 paused 一律不判。paused 是人設的,助理判它到期只會讓人以為它還在跑。 + # done 與 paused 一律不判到期。 T_SKIP=$(( T_SKIP + 1 )) - row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" + row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" "$_ov" continue fi @@ -914,7 +975,7 @@ scan_tasks() { unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;; esac row_out "$_id" "$R_VERDICT" "$_st" "$_kd" "$_ac" "$_tg" "$_rc" \ - "$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" + "$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" "$_ov" done [ "$T_TOTAL" -eq 0 ] && { RC_EMPTY=4 @@ -931,7 +992,7 @@ compose_md() { printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \ "$PREV" "$SEEN" printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n' - printf '這一節不判逾期(`due` 欄有沒有過),也不執行任何一筆。到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n' + printf '到期與逾期是兩件事,這一節兩件都判:到期是「該跑了」,逾期是「`due` 欄那個截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。這一節不執行任何一筆——到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n' printf '| 項目 | 內容 |\n' printf '| --- | --- |\n' printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")" @@ -943,10 +1004,12 @@ compose_md() { printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW" printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL" printf '| 到期 | %s |\n' "$T_DUE" + printf '| 逾期 | %s |\n' "$T_OVERDUE" printf '| 還沒到 | %s |\n' "$T_WAIT" printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID" printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED" printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP" + [ "$T_DUEBAD" -gt 0 ] && printf '| `due` 欄解不開 | %s |\n' "$T_DUEBAD" printf '\n#### 狀態來源\n\n' printf '| 來源 | 狀態 | 筆數 | 說明 |\n' @@ -975,19 +1038,36 @@ compose_md() { printf '本輪沒有新事件。\n' fi + printf '\n#### 逾期\n\n' + if [ "$T_OVERDUE" -gt 0 ]; then + printf '**這幾筆的截止時間過了。逾期每一輪都提,不因為提過就不再提**——助理只提醒,不代為執行,也不自己把它移出這一節。\n\n' + printf '| id | state | 截止時間 | 逾期多久 | 標題 |\n' + printf '| --- | --- | --- | ---: | --- |\n' + printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do + [ -n "$_oi" ] || continue + printf '| `%s` | %s | %s | %s | %s |\n' "$_oi" "$_os" "$(cell "$_od")" "$(dur_human "$_oa")" "$(cell "${_ot:--}")" + done + printf '\n被暫停的那幾筆也算在裡面:截止時間過了這件事不會因為有人按了暫停就消失,而一筆被暫停又逾期的交辦正好是最容易被忘掉的那一種。\n' + else + printf '沒有逾期的待辦。`due` 欄是空的那幾筆沒有截止時間,永遠不會逾期。\n' + fi + if [ "$T_DUEBAD" -gt 0 ]; then + printf '\n有 %s 筆的 `due` 欄解不開,上表 `overdue` 那一欄是 `bad`。**一個解不開的截止時間跟沒有截止時間,在畫面上長得一模一樣**,而前者是登錄的時候就填錯了:重新登錄一筆,`id` 由建立時間與標題算出來,改欄位值算不回同一個 id。\n' "$T_DUEBAD" + fi + printf '\n#### 逐筆判定\n\n' if [ -s "$ROWS" ]; then - printf '| id | 判定 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n' - printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n' + printf '| id | 判定 | 逾期 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n' + printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n' # 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會 # 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄 # 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。 awk -F'\t' ' function d(s) { return s == "" ? "-" : s } function esc(s) { gsub(/\|/, "\\|", s); return s } - NF >= 11 && $1 != "" { - printf "| `%s` | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \ - $1, $2, esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10)) + NF >= 12 && $1 != "" { + printf "| `%s` | %s | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \ + $1, $2, d($12), esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10)) }' "$ROWS" else printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n' @@ -1042,9 +1122,9 @@ cmd_scan() { # stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這 # 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。 if [ -s "$ROWS" ]; then - awk -F'\t' 'NF >= 11 && $1 != "" { - printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s why=%s title=%s\n", \ - $1, $2, $3, $4, $5, $8, $9, $6, $7, $10, $11 + awk -F'\t' 'NF >= 12 && $1 != "" { + printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s overdue=%s why=%s title=%s\n", \ + $1, $2, $3, $4, $5, $8, $9, $6, $7, $12, $10, $11 }' "$ROWS" fi printf 'tasks_total=%s\n' "$T_TOTAL" @@ -1053,8 +1133,18 @@ cmd_scan() { printf 'tasks_invalid=%s\n' "$T_INVALID" printf 'tasks_unwired=%s\n' "$T_UNWIRED" printf 'tasks_skipped=%s\n' "$T_SKIP" + printf 'tasks_overdue=%s\n' "$T_OVERDUE" + printf 'tasks_due_bad=%s\n' "$T_DUEBAD" + # 逾期的那幾筆逐筆點名,不只印一個數字。一份只有數字的回報讀不出「是哪一筆過期了」, + # 而那正是唯一有用的資訊;逾期的處置一律是人接手,人要知道接哪一筆。 + if [ -n "$OVERDUE_LINES" ]; then + printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do + [ -n "$_oi" ] || continue + printf 'overdue_row=%s state=%s due=%s overdue_secs=%s title=%s\n' "$_oi" "$_os" "$_od" "$_oa" "$_ot" + done + fi printf 'rows_file=%s\n' "$ROWS" - printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title\n' + printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title overdue\n' printf 'due_file=%s\n' "$MD" printf 'prev_file=%s\n' "$PREV" printf 'seen_file=%s\n' "$SEEN" @@ -1062,6 +1152,53 @@ cmd_scan() { finish_rc } +# 只判逾期,一律唯讀。 +# +# 為什麼另開一個子命令:逾期判定跟事件與到期判定不同,它一個狀態都不必比、一份快照都不必推進 +# ——只要把每一筆的 due 欄跟現在比一下。狀態查詢那一路每次都想知道「有沒有逾期的」,走 scan +# 就會推進快照,同一輪之後的事件判定會全部落空;走 events 又判不到逾期。 +# 讓呼叫端自己拿 due 欄去比更糟:那就是第二套判定,跟這一支的規則會漂移,而漂移的那一天 +# 兩邊都說自己是對的。 +cmd_overdue() { + while [ "$#" -gt 0 ]; do + case "$1" in + --now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;; + *) usage ;; + esac + done + resolve_now + T_TOTAL=0; T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES='' + if [ ! -d "$TASKS_DIR" ]; then + RC_EMPTY=4 + note "待辦簿目錄不存在($TASKS_DIR),這一輪沒有任何一筆可判。「沒判過」不等於「都沒逾期」。" + else + for _f in "$TASKS_DIR"/*; do + [ -f "$_f" ] || continue + T_TOTAL=$(( T_TOTAL + 1 )) + _id=$(kv_get "$_f" id); [ -n "$_id" ] || _id=$(basename "$_f") + _st=$(kv_get "$_f" state); [ -n "$_st" ] || _st=pending + _ti=$(kv_get "$_f" title) + _du=$(kv_get "$_f" due) + [ "$_st" = done ] && continue + _ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; } + case "$_ov" in + ''|*[!0-9]*) continue ;; + esac + T_OVERDUE=$(( T_OVERDUE + 1 )) + printf 'overdue_row=%s state=%s due=%s overdue_secs=%s human=%s title=%s\n' \ + "$_id" "$_st" "$_du" "$_ov" "$(dur_human "$_ov")" "$_ti" + done + [ "$T_TOTAL" -eq 0 ] && { RC_EMPTY=4; note "待辦簿目錄在($TASKS_DIR),但零筆。"; } + fi + printf 'now=%s\n' "$(epoch_to_iso "$NOW")" + printf 'tasks_total=%s\n' "$T_TOTAL" + printf 'tasks_overdue=%s\n' "$T_OVERDUE" + printf 'tasks_due_bad=%s\n' "$T_DUEBAD" + note '這是唯讀判定:一份快照都沒推進,一筆待辦都沒改。逾期的處置一律是人接手。' + cleanup_tmp + finish_rc +} + cmd_events() { while [ "$#" -gt 0 ]; do case "$1" in @@ -1149,6 +1286,7 @@ FIRST_RUN=0 EV_NAMES='' EV_NEW=0 T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0 +T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES='' CMD="${1:-}" [ -n "$CMD" ] || usage @@ -1156,6 +1294,7 @@ shift case "$CMD" in scan) cmd_scan "$@" ;; events) cmd_events "$@" ;; + overdue) cmd_overdue "$@" ;; next) cmd_next "$@" ;; *) usage ;; esac diff --git a/tools/patrol.sh b/tools/patrol.sh index b67f789..c29b627 100755 --- a/tools/patrol.sh +++ b/tools/patrol.sh @@ -148,6 +148,7 @@ # warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示 # 時,原因只寫在這裡 # tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用 +# tasks_overdue= 逾期筆數(截止時間已經過了),取自到期判定那一支;判不出來時是減號 # tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空 # events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空 # due_status= due_rc= 到期判定那一支的結果與結束碼 @@ -209,6 +210,8 @@ DUE_ROWS='' DUE_NOTE='' DUE_TASKS='' DUE_EVENTS='' +DUE_OVERDUE='' +FAILING_LINES='' # 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。 # 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉; @@ -875,7 +878,7 @@ d11() { # --- 待辦簿筆數(只供目錄頁那一個區塊用)--- count_tasks() { - TASKS_TOTAL=0; TASKS_FAILING=0 + TASKS_TOTAL=0; TASKS_FAILING=0; FAILING_LINES='' _d="$STATE_DIR/tasks" [ -d "$_d" ] && [ -r "$_d" ] || return 0 for _f in "$_d"/*; do @@ -883,7 +886,19 @@ count_tasks() { TASKS_TOTAL=$(( TASKS_TOTAL + 1 )) _fc=$(sed -n 's/^fail_count=//p' "$_f" 2>/dev/null | head -n1) case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac - [ "$_fc" -gt 0 ] && TASKS_FAILING=$(( TASKS_FAILING + 1 )) + if [ "$_fc" -gt 0 ]; then + TASKS_FAILING=$(( TASKS_FAILING + 1 )) + # 逐筆點名,不只累加。一個數字說得出「有幾筆在連續失敗」,說不出是哪幾筆,而失敗次數 + # 這個欄位存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」——不說出是哪一筆, + # 那個理由就只完成了一半。 + _ti=$(sed -n 's/^title=//p' "$_f" 2>/dev/null | head -n1) + _id=$(sed -n 's/^id=//p' "$_f" 2>/dev/null | head -n1) + [ -n "$_id" ] || _id=$(basename -- "$_f") + _lr=$(sed -n 's/^last_run=//p' "$_f" 2>/dev/null | head -n1) + FAILING_LINES="${FAILING_LINES}$(printf '%s\t%s\t%s\t%s' \ + "$_id" "$_fc" "${_lr:--}" "${_ti:--}") +" + fi done return 0 } @@ -920,6 +935,7 @@ due_scan() { DUE_ROWS=$(sed -n 's/^rows_file=//p' "$RD/due.out" 2>/dev/null | head -n1) DUE_TASKS=$(sed -n 's/^tasks_due=//p' "$RD/due.out" 2>/dev/null | head -n1) DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1) + DUE_OVERDUE=$(sed -n 's/^tasks_overdue=//p' "$RD/due.out" 2>/dev/null | head -n1) case "$_rc" in 0) DUE_STATUS=ok ;; 4) @@ -997,8 +1013,19 @@ compose() { cat "$RD/d07.md"; printf '\n' if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then cat "$DUE_MD" - printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逾期(`due` 欄已經過了)還沒實作,上面的判定只講到期,不講逾期。\n\n' \ + printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。\n\n' \ "$TASKS_TOTAL" "$TASKS_FAILING" + if [ -n "$FAILING_LINES" ]; then + printf '#### 連續失敗的那幾筆\n\n' + printf '**這幾筆每一輪都會再試一次,而且不會自動暫停。** 助理不替人按暫停:那個狀態留給人設,也只有人解得開。\n\n' + printf '| id | 已連續失敗 | 最後一次執行 | 標題 |\n' + printf '| --- | ---: | --- | --- |\n' + printf '%s' "$FAILING_LINES" | while IFS="$(printf '\t')" read -r _fi _fn _fl _ft; do + [ -n "$_fi" ] || continue + printf '| `%s` | %s 次 | %s | %s |\n' "$_fi" "$_fn" "$(cell "$_fl")" "$(cell "$_ft")" + done + printf '\n' + fi else printf '### 待辦簿到期與逾期\n\n' printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \ @@ -1129,6 +1156,17 @@ case "$CMD" in d11 count_tasks due_scan + # 逾期與連續失敗,每一輪都列一筆待人處理。 + # + # 這兩件事的處置都是人接手,而且**不會自己好**:一筆逾期的交辦不會因為過了更久就不逾期, + # 一筆連續失敗的檢查項每一輪都會再試一次然後再失敗一次。所以每一輪都提,不因為上一輪 + # 提過就這一輪不提——「提過了」不是「處理過了」,而助理不替人按暫停:那個狀態留給人設。 + if [ -n "$DUE_OVERDUE" ] && [ "$DUE_OVERDUE" -gt 0 ] 2>/dev/null; then + add_pending "有 $DUE_OVERDUE 筆待辦逾期,截止時間已經過了" '待辦簿到期與逾期' '/jsc-assist:assistant status' + fi + if [ "$TASKS_FAILING" -gt 0 ]; then + add_pending "有 $TASKS_FAILING 筆待辦連續失敗,每一輪都在重試" '待辦簿到期與逾期' '/jsc-assist:assistant status' + fi tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS" tally "$D11_STATUS" @@ -1159,6 +1197,7 @@ case "$CMD" in printf 'tasks_total=%s\n' "$TASKS_TOTAL" printf 'tasks_failing=%s\n' "$TASKS_FAILING" printf 'tasks_due=%s\n' "$DUE_TASKS" + printf 'tasks_overdue=%s\n' "${DUE_OVERDUE:--}" printf 'events_new=%s\n' "$DUE_EVENTS" printf 'due_status=%s\n' "$DUE_STATUS" printf 'due_rc=%s\n' "$DUE_RC" diff --git a/tools/run-due.sh b/tools/run-due.sh index 52dd554..2f9b284 100755 --- a/tools/run-due.sh +++ b/tools/run-due.sh @@ -306,9 +306,9 @@ RC_CMD=0; RC_WRITE=0 # 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。 _cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null) case "${_cols:-0}" in - 11) ;; - 0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=11 ;; - *) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 11 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。" ;; + 12) ;; + 0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=12 ;; + *) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 12 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。第 12 欄是逾期判定,跟這一支要跑什麼無關,但欄位數不對就代表兩支不是同一版,那時候前 11 欄的順序也不能假設還是原來那樣。" ;; esac while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recur c_next c_rearm c_why c_rest; do