Compare commits
39
Commits
1d220bf03f
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ada337b8 | ||
|
|
61f4a2dbc7 | ||
|
|
404cf8f4fd | ||
|
|
2265dcd59c | ||
|
|
3dc6b32088 | ||
|
|
932bfa826a | ||
|
|
4cbd0e360f | ||
|
|
6a2cf045ee | ||
|
|
465bd9d40a | ||
|
|
205da9b61f | ||
|
|
e53bb23f8e | ||
|
|
98dd0761fa | ||
|
|
f6a293ddab | ||
|
|
633f56e7ea | ||
|
|
6cb071a3e7 | ||
|
|
0df1605a20 | ||
|
|
4562da2bea | ||
|
|
ccb28e2bb3 | ||
|
|
06c5f3aaaf | ||
|
|
a050404a77 | ||
|
|
ebce7fc0c3 | ||
|
|
cc5faaff21 | ||
|
|
ffe9a20178 | ||
|
|
2c0718ee19 | ||
|
|
044676b1fc | ||
|
|
6dbf2f1a22 | ||
|
|
3784cb51be | ||
|
|
dc7c164d9a | ||
|
|
7d40c9dded | ||
|
|
bccdbbbd24 | ||
|
|
cd83b1b62a | ||
|
|
c76e8489a6 | ||
|
|
eade09bf62 | ||
|
|
85a0ce9e10 | ||
|
|
f1bc17ac97 | ||
|
|
28f453176e | ||
|
|
1f02c5843d | ||
|
|
a168c5904c | ||
|
|
e2d7454ee6 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.7",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
@@ -19,7 +19,8 @@
|
||||
"jsc-cli": ">=0.2.7",
|
||||
"jsc-gitea": ">=0.2.0",
|
||||
"jsc-hooks": ">=0.3.7",
|
||||
"jsc-log": ">=0.1.4"
|
||||
"jsc-log": ">=0.1.4",
|
||||
"jsc-meta": ">=0.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.7",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"jsc": {
|
||||
@@ -8,7 +8,8 @@
|
||||
"jsc-cli": ">=0.2.7",
|
||||
"jsc-gitea": ">=0.2.0",
|
||||
"jsc-hooks": ">=0.3.7",
|
||||
"jsc-log": ">=0.1.4"
|
||||
"jsc-log": ">=0.1.4",
|
||||
"jsc-meta": ">=0.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ 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/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` 回「查詢失敗」時照原字抄,不補查、不美化 |
|
||||
| `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` 回「查詢失敗」時照原字抄,不補查、不美化。`collect` 另外把要送到人面前的那幾筆寫成提醒佇列 `$JSC_HOME/assistant/reminders.tsv`:第一行帶輪次與 UTC 時間戳,之後一行一筆,到期的只提醒項與逾期項各一種。判定留在到期判定那一支,佇列只是輸出——讀的那一端只印、不判。時間戳是關鍵:沒有人更新的佇列讀起來跟新的一模一樣 |
|
||||
| `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 才寫;但那一條含主機位址與網址編碼,會變,所以不當鍵 |
|
||||
| `templates/monitor-page.md` | 內容頁 `MONITOR_{HASH}` 的範本。記的是這台機器的巡檢軌跡。頁面固定三塊:本頁基本資料建頁時寫一次就不動、最新一輪每輪整塊換掉、近 24 輪摘要一輪一列且最新的在最上面。軌跡留在摘要表,完整內容只留最新一輪,頁面才讀得完 |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.7",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills/",
|
||||
"jsc": {
|
||||
@@ -8,7 +8,8 @@
|
||||
"jsc-cli": ">=0.2.7",
|
||||
"jsc-gitea": ">=0.2.0",
|
||||
"jsc-hooks": ">=0.3.7",
|
||||
"jsc-log": ">=0.1.4"
|
||||
"jsc-log": ">=0.1.4",
|
||||
"jsc-meta": ">=0.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+119
-25
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: assistant
|
||||
description: 'Start, inspect, patrol or stop the background assistant: jsc-hooks/hooks/heartbeat.sh owns the freshness verdict, tools/schedule.sh the system scheduler, tools/patrol.sh one round. The heartbeat is written by a completed round and by nothing else, so the schedule carries the patrol entry only, its period from the heartbeat TTL; start runs one round then installs that entry - absolute CLI path, environment snapshot, unattended write confirmation, which cron lacks - status prints heartbeat, schedule and task book read-only, stop removes the entry before clearing the heartbeat. One round reads five independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, the heartbeat''s own report, and the status event stream that jsc-hooks/tools/report-status.sh drains and rotates, whose starts with no matching end are the only evidence an earlier skill run aborted - then rewrites wiki MONITOR_{HASH} through jsc-gitea:wiki as three fixed blocks - basic data untouched, the latest round replaced whole, a 24-row summary table - and upserts its MONITOR_CONTENTS entry through jsc-gitea/tools/wiki-contents.sh, which reads the separate CONTENTS wiki repo and keeps one H2 block per machine - the heading is the monitor page''s own name, the fields are bullets under it, and one of them links that page by its absolute wiki-url. Every link on either page is written as [text](URL) and is verified by jsc-gitea/tools/link-check.sh before that page is written, so a dead link stops the write instead of landing on the page. A round that cannot record its result writes no heartbeat; one that starts while the previous holds the lock stands down. Use when someone starts, patrols or stops the assistant, or asks whether it runs and what is queued; not for environment health checks (jsc-cli:doctor), not for skill usage counts (jsc-log:stats).'
|
||||
description: 'Start, inspect, patrol or stop the background assistant: jsc-hooks/hooks/heartbeat.sh owns the freshness verdict, tools/schedule.sh the system scheduler, tools/patrol.sh one round. The heartbeat is written by a completed round and by nothing else, so the schedule carries the patrol entry only, its period from the heartbeat TTL; start runs one round then installs that entry - absolute CLI path, environment snapshot, unattended write confirmation, which cron lacks - status prints heartbeat, schedule and task book read-only, stop removes the entry before clearing the heartbeat. Before that first round, start reconciles the built-in check items against jsc-meta''s delegation list through tools/seed-tasks.sh - seeding and rebuilding are one idempotent call, conditional rows are held back with their condition printed, and an origin user entry is never touched; status reports the same comparison through plan, which writes nothing, and a patrol round runs neither. One round reads five independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, the heartbeat''s own report, and the status event stream that jsc-hooks/tools/report-status.sh drains and rotates, whose starts with no matching end are the only evidence an earlier skill run aborted - then rewrites wiki MONITOR_{HASH} through jsc-gitea:wiki as three fixed blocks - basic data untouched, the latest round replaced whole, a 24-row summary table - and upserts its MONITOR_CONTENTS entry through jsc-gitea/tools/wiki-contents.sh, which reads the separate CONTENTS wiki repo and keeps one H2 block per machine - the heading is the monitor page''s own name, the fields are bullets under it, and one of them links that page by its absolute wiki-url. Every link on either page is written as [text](URL) and is verified by jsc-gitea/tools/link-check.sh before that page is written, so a dead link stops the write instead of landing on the page. A round that cannot record its result writes no heartbeat; one that starts while the previous holds the lock stands down. Use when someone starts, patrols or stops the assistant, or asks whether it runs and what is queued; not for environment health checks (jsc-cli:doctor), not for skill usage counts (jsc-log:stats).'
|
||||
---
|
||||
|
||||
# assistant — start, status, patrol, stop
|
||||
@@ -24,17 +24,21 @@ The root comes from outside this skill. `schedule.sh` resolves it while a person
|
||||
| Who invoked this round | Where `{CURRENT}` comes from |
|
||||
| --- | --- |
|
||||
| the schedule — an unattended round, the `patrol` whose trigger is 排程 | the path after `工具根目錄=` in the invocation text, taken verbatim. No command is run |
|
||||
| a person, in front of the terminal | the same token when the invocation carries one; otherwise `readlink -f "$JSC_HOME/current"`, run once |
|
||||
| a person, in front of the terminal | the same token when the invocation carries one; otherwise `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"`, run once |
|
||||
|
||||
**An unattended round that finds no root in its invocation stops there.** Report that the scheduled entry carries no `工具根目錄=` — an entry written by an older `schedule.sh` — say the fix is to run `start` again, or `jsc-assist/tools/schedule.sh install patrol` under `$JSC_HOME/current`, so the entry is rewritten with the root in it. Then take the operation's `aborted` status, write the `skill-end`, and stop.
|
||||
|
||||
Never work the root out instead. `readlink -f "$JSC_HOME/current"`, `ls -d "$JSC_HOME/current"` and every other resolve are refused in an unattended session — measured, see the table below — so running one does not produce a root, it produces a round that stops one step earlier having recorded nothing. Never fall back to `$JSC_HOME/current` as a written-out path either, never take a path from the plugin prompt or a previous transcript, and never guess.
|
||||
Never work the root out instead. `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"`, `ls -d "$JSC_HOME/current"` and every other resolve are refused in an unattended session — measured, see the table below — so running one does not produce a root, it produces a round that stops one step earlier having recorded nothing. Never fall back to `$JSC_HOME/current` as a written-out path either, never take a path from the plugin prompt or a previous transcript, and never guess.
|
||||
|
||||
**Only an attended invocation may resolve the root itself.** `readlink -f "$JSC_HOME/current"` covers the documented `~/.jsc` fallback in the same call and prints one literal absolute path. It raises one permission prompt, and a person is there to answer it once. That is the whole reason the branch exists: portability survives where somebody can approve it, and nowhere else.
|
||||
**Only an attended invocation may resolve the root itself.** `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"` prints one literal absolute path. It raises one permission prompt, and a person is there to answer it once. That is the whole reason the branch exists: portability survives where somebody can approve it, and nowhere else.
|
||||
|
||||
**The `:-$HOME/.jsc` half is load-bearing, and dropping it is how this line was wrong before.** `$JSC_HOME` is documented as defaulting to `~/.jsc`, and the shell does not know that: with the variable unset, a bare `$JSC_HOME/current` expands to `/current`, which is not a fallback to anything. Write the default into the expansion or the documented default never happens.
|
||||
|
||||
Take the root once per invocation and reuse that one answer. Never resolve it again per call, never print it as a report line of its own, and never add a tool that prints it. Never test the root with a command either — an unattended round cannot, and the first script call is the test that matters anyway.
|
||||
|
||||
An empty token, an empty `readlink` result, a path that is not absolute, or a resolved path that is not an existing directory means there is no root to work with. **That fourth item is the one the other three wave through.** With `JSC_HOME` unset, `readlink -f "$JSC_HOME/current"` prints `/current` and exits 0 — non-empty, absolute, and past every other item — and each literal path built from it then names a place that is not there. So the attended resolve is only accepted once `[ -d "{the path just printed}" ]` says that directory exists, run in the same approved step as the resolve itself. The unattended round tests nothing, exactly as above: its root was written into the entry by whoever installed the schedule, and its first script call is what fails if that root is wrong. Report it, say `jsc-cli:deploy` has to run, take the operation's `aborted` status, and stop. Never fall back to a cache path, and never create the root here. Completion condition: one literal absolute path is in hand and every later command line carries it, or the missing root was reported and the operation stopped.
|
||||
An empty token, an empty `readlink` result, a path that is not absolute, or a resolved path that is not an existing directory means there is no root to work with. **That fourth item is the one the other three wave through**, so the attended resolve is only accepted once `[ -d "{the path just printed}" ]` says that directory exists, run in the same approved step as the resolve itself. It stays a separate check even now that the expansion carries its own default, because the ways a resolve can print a plausible path that is not there do not end with an unset variable: `JSC_HOME` set to a directory that no longer exists, `HOME` unset in a stripped environment, or the link farm simply never deployed all reach this line with something absolute in hand.
|
||||
|
||||
**How that guard earned its place is worth keeping.** This step used to resolve a bare `$JSC_HOME/current`, and on a machine that had never set `JSC_HOME` — the documented default being `~/.jsc` — it printed `/current` and exited 0: non-empty, absolute, and past every other item. The directory check was the only thing that caught it, and it caught it while pointing at the wrong culprit, because a resolve that cannot be trusted looks exactly like a root that is not there. The expansion above is the fix; this check is what noticed. The unattended round tests nothing, exactly as above: its root was written into the entry by whoever installed the schedule, and its first script call is what fails if that root is wrong. Report it, say `jsc-cli:deploy` has to run, take the operation's `aborted` status, and stop. Never fall back to a cache path, and never create the root here. Completion condition: one literal absolute path is in hand and every later command line carries it, or the missing root was reported and the operation stopped.
|
||||
|
||||
## Every script call carries a literal absolute path
|
||||
|
||||
@@ -57,6 +61,8 @@ The reason is the permission layer: it matches its rules against the command tex
|
||||
|
||||
A literal allow rule that itself starts with `$JSC_HOME` was added to the settings file and the same call was still refused, so no permission rule makes the variable form work either. The literal path is the whole fix, on both sides.
|
||||
|
||||
Row 4 was measured before the resolve carried its own default, and the verdict is the same for `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"`: the rule is matched against the text as written, and that text still holds variables. The default fixed which path the resolve produces, not whether an unattended round may run it — that round still gets its root handed in, and this line stays the attended branch only.
|
||||
|
||||
**These rules outrank portability, and the next maintainer is the one who has to know why.** A variable in the path reads as the portable choice and costs nothing while a person is watching: the prompt appears, somebody approves it, the round carries on. The scheduled round has nobody to approve it. It stops at its first script call, records nothing, writes no heartbeat, and the machine then reads as a stopped assistant with no trace of the refusal anywhere. And the resolve is no way out of that, because row 4 of the table is the resolve itself: a round that cannot run a script cannot run the command that would have told it which script to run. That is why the root is handed in by whoever installed the schedule, and why anything written into a command line here is already literal.
|
||||
|
||||
## Tool paths
|
||||
@@ -65,17 +71,23 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta
|
||||
|
||||
| What it does | Path to run |
|
||||
| --- | --- |
|
||||
| one patrol round | `{CURRENT}/jsc-assist/tools/patrol.sh` |
|
||||
| one patrol round, and joining the monitor page's three blocks | `{CURRENT}/jsc-assist/tools/patrol.sh` |
|
||||
| the system scheduler | `{CURRENT}/jsc-assist/tools/schedule.sh` |
|
||||
| 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` |
|
||||
| the `MONITOR_CONTENTS` entry | `{CURRENT}/jsc-gitea/tools/wiki-contents.sh` |
|
||||
| the link check every write depends on | `{CURRENT}/jsc-gitea/tools/link-check.sh` |
|
||||
|
||||
**The delegation list is read across a plugin boundary, and the same rule applies to it.** `seed-tasks.sh` reads `{CURRENT}/jsc-meta/tools/delegate-spec.tsv`, so the root goes to it as `--root {CURRENT}` — the literal absolute path step 0 already took — and it resolves nothing for itself. `jsc-meta` is declared in this plugin's `jsc.requires` for that reason; when it is not installed the script exits 1 and changes not one entry, because a list that cannot be read is indistinguishable from a list saying nothing is delegable, and acting on the second reading deletes every built-in item at once.
|
||||
|
||||
**A `Skill(...)` rule permits invoking that skill and nothing more.** Every Bash call inside it is still checked on its own, so `jsc-gitea:wiki` reaching the wiki depends on `gitea.sh` carrying its own rule, the directory entry depends on `wiki-contents.sh` carrying one too, and both writes depend on `link-check.sh` carrying one — without them the round is refused locally, before any request leaves the machine, and the page never gets written.
|
||||
|
||||
**Never build a tool path out of the base directory the CLI hands you in the skill prompt.** That directory points into the plugin cache and carries a version segment, and the permission gate allows exactly the seven paths above and nothing else. A cache path is therefore refused silently: the round stops on a permission prompt nobody can answer, records nothing, writes no heartbeat, and the refusal looks exactly like a broken tool. Read the paths off this table every time — not off the prompt, not off a previous transcript, not off `crontab -l`.
|
||||
**Never build a tool path out of the base directory the CLI hands you in the skill prompt.** That directory points into the plugin cache and carries a version segment, and the permission gate allows exactly the paths above and nothing else. A cache path is therefore refused silently: the round stops on a permission prompt nobody can answer, records nothing, writes no heartbeat, and the refusal looks exactly like a broken tool. Read the paths off this table every time — not off the prompt, not off a previous transcript, not off `crontab -l`.
|
||||
|
||||
Both scripts check this for themselves: run from anywhere outside `{CURRENT}`, they print a `[WARN]` line on stderr naming the path they were started from and the path they should have been started from, and then carry on. That line means this round is on the wrong path — quote it, fix the path, and do not treat the round's success as proof that the path was fine.
|
||||
|
||||
@@ -127,7 +139,8 @@ The call never changes the outcome: it returns 0 even when it cannot write, and
|
||||
| --- | --- | --- |
|
||||
| `$JSC_HOME/assistant/heartbeat` | `heartbeat.sh` only, never this skill | `key=value` lines: `ts`, `pid`, `cli`, `session` |
|
||||
| `$JSC_HOME/assistant/schedule.log` | nobody here — the scheduled entry appends to it | free text; point the operator at it when a scheduled round misbehaves |
|
||||
| `$JSC_HOME/assistant/tasks/{id}` | this skill, read-only | `key=value` lines, one task per file: `id`, `kind` (`check` / `todo`), `title`, `action`, `trigger`, `recur`, `repo`, `due`, `state` (`pending` / `done` / `paused`), `last_run`, `next_run`, `fail_count`, `origin` (`user` / `assistant`) |
|
||||
| `$JSC_HOME/assistant/tasks/{id}` | this skill reads it directly; every write goes through `tasks.sh` | `key=value` lines, one task per file: `id`, `created`, `kind` (`check` / `todo`), `title`, `action`, `trigger`, `recur`, `repo`, `due`, `state` (`pending` / `done` / `paused`), `last_run`, `next_run`, `fail_count`, `origin` (`user` / `assistant`), `spec_key` (`jsc-{domain}:{skill}` for a built-in item, empty for anything a person asked for) |
|
||||
| `{CURRENT}/jsc-meta/tools/delegate-spec.tsv` | `seed-tasks.sh` only, read-only | the delegation list, tab-separated, one skill per row. `verdict`, `way`, `trigger` and `recur` decide whether a skill gets a built-in check item and on what schedule; column 12, `probe`, decides what that item's `action` actually is — a one-line read-only command, `pending:{reason}` for a slice whose entry point is not wired yet, or `-` for a row that has none. A list with only 11 columns predates that column and is handled as if every row said `-` |
|
||||
| `$JSC_HOME/assistant/patrol.lock/` | `patrol.sh` only | the round lock, a directory. `info` holds `round`, `pid`, `started` |
|
||||
| `$JSC_HOME/assistant/patrol/` | `patrol.sh` only | one round's scratch files, including `latest.md`, `summary.md`, `summary-row.md`, `newpage.md` and `contents-entry.md` |
|
||||
| `$JSC_HOME/assistant/usage-prev.tsv` | `patrol.sh` only | last recorded round's cumulative usage counts, so the next round can print a real per-round delta |
|
||||
@@ -173,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 `</dev/null`, so nothing it runs can block on input. A patrol round that stops to ask for a tool permission hangs that round, and the lock it holds stands the next round down until the lock ages out — which is why `patrol` asks nothing, of anybody, ever.
|
||||
- **The entry carries its own environment.** cron gives it a short `PATH`, no settings file and no tty, so `schedule.sh` writes three things into the entry: the CLI resolved to an absolute path with `command -v`, a snapshot of the wiki variables taken at install time (`GITEA_HOST`, `GITEA_TOKEN`, `JSC_HOME`, `JSC_ASSISTANT_HEARTBEAT_TTL` and every set `JSC_WIKI_REPO*` — `JSC_WIKI_REPO`, `JSC_WIKI_REPO_MONITOR` for the monitor page and `JSC_WIKI_REPO_CONTENTS` for the directory page, which the script picks up from the environment rather than from a hardcoded list), and `JSC_GITEA_CONFIRM=yes`, because the write confirmation only recognises a tty and an unattended round has nobody to confirm. Two consequences belong in every report: the entry holds a copy of the token, so the crontab file has to stay readable by its owner alone, and a changed variable only reaches the entry after another `install`. `install` prints the snapshotted names in `env_snapshot=` and masks the token in every entry it prints — never print an entry read from `crontab -l` yourself.
|
||||
- **The entry carries its own environment.** cron gives it a short `PATH`, no settings file and no tty, so `schedule.sh` writes three things into the entry: the CLI resolved to an absolute path with `command -v`, a snapshot of the wiki variables taken at install time (`GITEA_HOST`, `GITEA_TOKEN`, `JSC_HOME`, `JSC_ASSISTANT_HEARTBEAT_TTL`, `JSC_ASSIST_SCAN_ROOT` and `JSC_ASSIST_SCAN_EXCLUDE` for the repository scan, and every set `JSC_WIKI_REPO*` — `JSC_WIKI_REPO`, `JSC_WIKI_REPO_MONITOR` for the monitor page and `JSC_WIKI_REPO_CONTENTS` for the directory page, which the script picks up from the environment rather than from a hardcoded list), and `JSC_GITEA_CONFIRM=yes`, because the write confirmation only recognises a tty and an unattended round has nobody to confirm. Two consequences belong in every report: the entry holds a copy of the token, so the crontab file has to stay readable by its owner alone, and a changed variable only reaches the entry after another `install`. `install` prints the snapshotted names in `env_snapshot=` and masks the token in every entry it prints — never print an entry read from `crontab -l` yourself.
|
||||
- **`install` prints the permission rules that round needs.** One `allow_rule=` line each, every path a full literal under `current` — no variable, no tilde, and no wildcard inside the path, because a rule holding one matches nothing. Hand them to the operator verbatim: an unattended round that hits a permission prompt hangs until the lock ages out, and nobody is there to approve it. `Write(...)` rules do nothing for file writes — only `Edit(...)` is recognised — so never turn a printed `Edit` rule into a `Write` one.
|
||||
- **`install` writes the tool root into the entry.** The round it schedules cannot resolve the root for itself, so `schedule.sh` puts the literal path into the entry's prompt as `工具根目錄={path}` and prints the same value as `patrol_root=`. Report that value, and treat any hand-edit of the entry that drops it as breaking every future round: from then on each one stops at step 0 with nothing recorded.
|
||||
|
||||
@@ -226,14 +239,77 @@ One table for all three subcommands. Read `collect`'s codes carefully: **1 and 3
|
||||
| 5 | Filesystem failure — the lock could not be created or released, a scratch file could not be written, the snapshot could not be promoted, or `heartbeat.sh write` returned non-zero | Serious. Report it loudly with the stderr text and the path. On a `finish` failure the round is recorded but unproven: say so plainly and never claim the round beat |
|
||||
| 6 | Usage error — an unknown subcommand, a missing `--round`, or an option with no value | A defect in the call. Correct it and run it once more; report a second exit 6 as a defect in this skill and stop |
|
||||
|
||||
## Built-in check items and the delegation list
|
||||
|
||||
The delegation list holds one row per jsc skill and records whether that skill can be handed to the background assistant. Every row that is delegable and carries a `trigger` and a `recur` earns one `check` entry in the task book, with `origin: assistant` and `spec_key` set to `jsc-{domain}:{skill}`. `{CURRENT}/jsc-assist/tools/seed-tasks.sh` is the only thing that creates or removes those entries, and it does it by reconciling the two sides rather than by remembering whether it has run before:
|
||||
|
||||
| Situation | What the reconcile does |
|
||||
| --- | --- |
|
||||
| The list has a delegable row, the task book has no entry for it | add one, `origin: assistant`, `spec_key` set |
|
||||
| The task book has an entry whose `spec_key` is no longer a delegable row — removed from the list, or changed to `none` | remove that entry, so no orphan is left pointing at a skill nobody delegates any more |
|
||||
| The entry is `origin: user` | leave it exactly as it is, always. A skill being re-judged is never a reason to delete something a person asked for |
|
||||
| The row is still delegable but its `trigger`, `recur` or `way` changed | report it as `drift=` and change nothing, unless `--refresh` was passed |
|
||||
| The row's `verdict` is `cond` | hold it: seed nothing, print a `held=` line carrying the condition text, unless `--allow-cond {key}` named that row |
|
||||
|
||||
## What a built-in item actually does, and the `probe` column
|
||||
|
||||
`way` decides the shape of the entry's `action`, and column 12 `probe` decides the rest of it:
|
||||
|
||||
| `way` | `probe` | `action` | Also printed |
|
||||
| --- | --- | --- | --- |
|
||||
| contains `invoke` | ignored entirely | the skill name | a `probe_bad=` line if `probe` held a command — the list's own header says an `invoke` row carries `-`, and honouring a command there would turn a whole delegated skill into one script call that writes none of the pages that skill exists to write, while looking perfectly healthy |
|
||||
| `patrol`, `remind`, `patrol,remind` | a one-line command | that command, substituted | a `probe=` line with the command and its remaining holes |
|
||||
| `patrol`, `remind`, `patrol,remind` | `pending:{reason}` | `remind` | a `pending=` line carrying the reason verbatim |
|
||||
| `patrol`, `remind`, `patrol,remind` | `-`, empty, or the whole list is 11 columns | `remind` | nothing — this is the behaviour that predates the column |
|
||||
|
||||
**A `probe` that cannot be substituted falls back to `remind` and is reported, never dropped.** The path is not `{root}/jsc-{domain}/...`, the command carries a `$` or a `~`, a brace other than the three known holes survived, the root could not be resolved, or the script is not on this machine: each prints `probe_bad=` and the entry is still seeded, as a reminder. Not seeding it would mean removing it on `apply`, so one mistyped cell upstream would delete an entry carrying its own `last_run` and `fail_count` history.
|
||||
|
||||
**`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** `run-due.sh` is what executes one, and it is the only thing that does: `tasks.sh` stores the value, `due.sh` prints it, `status` and the monitor page print it. That tool substitutes `{cli}` from `jsc-cli/tools/detect-clis.sh` and `{repo}` from `jsc-assist/tools/scan-repos.sh`, then runs once per target, with the targets' results together counting as the entry's one success or failure. An entry carrying both holes runs once per pair — three repositories and five CLIs is fifteen targets. An entry whose `repo=` field names a repository is given that one repository only, matched against the scan by working directory or by `{owner}/{repo}`; a name the scan did not find is held rather than widened back to every repository, because that entry asked for one target. **The scan refuses to guess its starting point**: without `JSC_ASSIST_SCAN_ROOT` it reports that and nothing else, because guessing one layer too high turns the working directory into the home directory and then into the whole machine, and the commands would run there with nobody watching. So an entry carrying `{repo}` on a machine where that variable never reached the scheduled entry is held with the scan's own words as the reason — that is what tells a person which variable to set and that a fresh `schedule.sh install patrol` is what carries it into the entry. **Holding is not failing**: an entry that cannot be given a target did nothing wrong, and recording it as a failure grows a counter nobody can bring down by fixing that entry, which is exactly what that counter exists to make visible.
|
||||
|
||||
**A `pending` row stays a reminder but is never reported as an ordinary one.** The reason lives in the report, as a `pending=` line, and nothing is written into the task file. Putting the marker in the title would change the `id` and cut that entry's history, and the drift check compares `kind`, `action`, `trigger` and `recur` but not the title, so a stale marker would never be caught; adding a sixteenth key would change the task book's fixed storage format for a piece of upstream prose the task book has no way to edit later. `status` runs `plan`, so `pending=`, `held=` and `drift=` all reach a human in the same place.
|
||||
|
||||
**Neither repository's merge order can break the other.** `seed-tasks.sh` measures the list's column count once: 12 or more and `probe` applies, 11 or fewer and every non-`invoke` row seeds as `remind` exactly as before, with a single note saying why rather than one warning per row. On the round after `jsc-meta` gains the column, the four command rows show up as `drift=` and change nothing until a human passes `--refresh` — report that as the expected transition, not as a fault.
|
||||
|
||||
**Seeding and rebuilding are the same call.** There is no first-run flag and no second code path: what happens is decided by comparing the list against the task book, so the first call seeds every item and every later call is a no-op until the list actually changes. That is why `start` runs it every time rather than only once.
|
||||
|
||||
**A `cond` row is held back on purpose, and the report has to say so.** The condition lives in prose in the list, so no script can evaluate it, and one of them says in as many words that its own scripts still resolve through version-carrying paths — seeding it would have the assistant invoke, every single round, something that stops on its first script call with no error, no output and a heartbeat that still looks healthy. So the default is to hold, and every held row is printed with its condition and the half that stays with a human. Never quietly drop them: a missing item nobody can account for is the failure this reporting prevents.
|
||||
|
||||
## seed-tasks.sh exit codes
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
| --- | --- | --- |
|
||||
| 0 | The two sides are reconciled — `plan` printed its verdict, or `apply` made the changes. Zero changes is this code too | Carry on, and carry the `added=`, `removed=`, `kept=`, `drift=`, `held=`, `bad=`, `probe=`, `pending=` and `probe_bad=` counts into the report |
|
||||
| 1 | The delegation list could not be read, so **nothing was touched** | Report `jsc-meta` as missing or unreadable and say the built-in items were left exactly as they were. Never report this as "the list has no delegable skills" |
|
||||
| 2 | The list was read but not one delegable row came out of it, so **nothing was touched** | Report the list itself as suspect — a half-synced or damaged list would otherwise delete every built-in item. Point at the file and stop |
|
||||
| 3 | `tasks.sh` was not found, so **nothing was touched** | Report the installation as incomplete: the task book has exactly one writer and it is missing |
|
||||
| 4 | At least one `add` or `remove` failed; the rest were done | Report every `add_failed=` and `remove_failed=` line with the `tasks.sh` exit code it carries, and judge each by that script's own code table |
|
||||
| 5 | Filesystem failure — the scratch directory or a scratch file could not be written | Report it with the path; the reconcile could not even be computed |
|
||||
| 6 | Usage error — an unknown subcommand or option, a `--root` that is not absolute, or an `--allow-cond` value that is not `jsc-{domain}:{skill}` | A defect in the call. Correct it and run it once more |
|
||||
|
||||
## run-due.sh exit codes
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
| --- | --- | --- |
|
||||
| 0 | The round's due entries were dealt with. Zero due entries, and every entry held for want of a target, are both this code | Carry on, and carry the `done=`, `failed=`, `held=`, `skip=` and `write_failed=` counts into the report |
|
||||
| 1 | At least one entry's command returned non-zero. That entry is already recorded as failed and its failure count went up; the rest still ran | Report every `failed=` and `target_fail=` line with the command and the first line of its output. This is a finding about the thing that was checked, not a fault in the round |
|
||||
| 2 | The due list could not be read, or its column count is not the one this tool knows, so **nothing ran** | Report it and say the judging step has not run, or its output format changed. Never read this as "nothing was due" |
|
||||
| 4 | At least one write-back to `tasks.sh` failed. The command ran but the task book did not record it, so the same entry runs again next round | Say that out loud with the `write_failed=` lines and their `tasks.sh` codes — a command that runs every round and is never recorded is the failure mode this code exists to name |
|
||||
| 5 | Filesystem failure — a scratch file could not be written | Report it with the path |
|
||||
| 6 | Usage error — an unknown sub-command or option, a missing option value, or a `--root` that is not absolute | A defect in the call. Correct it and run it once more |
|
||||
|
||||
**A held entry is not a failed entry.** `held=` covers an entry whose `{cli}` or `{repo}` could not be given a target, and one whose command no longer passes the shape check. Its `last_run` and `fail_count` are left exactly as they were, and the report has to keep that distinction: an entry nobody could give a target to did nothing wrong, and letting its failure count climb buries the entries that really are broken under ones that are merely unwired.
|
||||
|
||||
## Boundaries
|
||||
|
||||
The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. Four of them need saying out loud here:
|
||||
|
||||
- **This skill never judges a gate.** It maintains the heartbeat and prints what the heartbeat says. Whether a stale heartbeat blocks a skill call is decided by a hook, synchronously and offline; nothing in this skill blocks or waves through anything. 界線 2.
|
||||
- **A command the permission layer refuses is reported with its full command line, wherever in the round it happened.** A refusal is not an error the tool returned: there is no exit code, no stderr, and nothing in the tool's own log. The command text is the only evidence, and it is the only thing that can be checked against the allow list — which matches the text as written, before the shell expands anything. So quote the line verbatim, prefix included, and name the step that submitted it. 界線 1 says a round never asks; this is what makes a refused round diagnosable instead of merely reported. One round wrote 「the write was refused」 and named neither the command nor the tool: every obvious suspect was ruled out afterwards and the real one was never found, so that round is still unexplained.
|
||||
- **A patrol round asks nothing.** It runs from cron with nobody present, so there is no one to answer and a question hangs the round. Every branch in the patrol steps below resolves without a question: a missing source is recorded as missing, an ambiguous result is recorded verbatim, and a round that cannot proceed aborts and reports. Never call `jsc-ask:ask` from `patrol`. A command that is not on the allow list is a question too — the permission prompt is one, and it is the one nobody sees — which is why step 0 hands that round its root instead of letting it resolve one. 界線 1.
|
||||
- **A patrol round rewrites the monitor page as three fixed blocks.** Read the old page back first; keep 本頁基本資料 as it stands, replace 最新一輪 whole, put this round's row on top of the summary table and cut it to 24; then put the whole page. The directory page is a separate write in a separate wiki repo, and `wiki-contents.sh` does it: that page keeps one H2 block per machine, and this machine's block is the only one that is updated. A page that could not be read is a page that does not get written — the summary table only survives if the old one came back. 界線 4.
|
||||
- **A patrol round reports; it never acts on what it found.** The 待人處理 rows name an entry point for a human. The patrol does not run that entry point, does not fix a hook, does not update a plugin and does not touch a repository. 界線 3 and 界線 6.
|
||||
- **Running the due built-in items is not an exception to that.** Those entries are the assistant's own scheduled work, put there by a reconcile against a delegation list that goes through review; a 待人處理 row is a finding about somebody else's machine state. The first is a round doing the job it was given, the second is a round deciding what somebody else's job is. Step 5 keeps the line where it belongs by running only read-only probe commands from the list, never the skill named by an `invoke` row and never anything a person entered by hand — and the moment a command that writes appears in that column, this paragraph is the one that has to be re-argued, not quietly widened.
|
||||
- **Only a human-initiated operation reconciles the built-in items; an unattended round reports the difference and stops there.** `start` runs `seed-tasks.sh apply`, because somebody asked for it and is there to read what it added and removed. `status` runs `seed-tasks.sh plan`, which writes nothing. `patrol` runs neither: removing a check entry destroys that entry's `last_run` and `fail_count` history, and 界線 5 keeps destructive cleanup with the human — a round that deletes a row at three in the morning because the list was mid-sync leaves nobody able to see that the row ever existed. The consequence is worth stating: on a machine nobody starts or inspects, a list change reaches the task book only at the next `start`. 界線 3 and 界線 5.
|
||||
- **`stop` clearing the heartbeat and removing the schedule is not a breach of 界線 5「不刪除狀態檔」.** That limit protects state that records work — the task book, worktrees, wiki pages — from a background process nobody is watching. The heartbeat records one fact only, "the last patrol round finished", and the schedule entry is what keeps rounds running, so a `stop` that leaves either behind leaves a lie behind. Clearing both is the whole job of `stop`, and they are the only deletions any operation here performs, both of them entries this skill installed itself. `stop` touches nothing under `tasks/`, nobody else's cron entry, no worktree and no wiki page. Do not "restore" this limit later by taking either removal out of `stop`.
|
||||
|
||||
## Crash exit needs no cleanup
|
||||
@@ -246,29 +322,37 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by
|
||||
|
||||
## start
|
||||
|
||||
`start` proves the loop works before it schedules it: one patrol round first, then the scheduled entry. It installs no daemon and writes no bare heartbeat.
|
||||
`start` proves the loop works before it schedules it: the built-in items first, then one patrol round, then the scheduled entry. It installs no daemon and writes no bare heartbeat.
|
||||
|
||||
1. **Run one patrol round.** Follow every step of the `patrol` operation below, start to finish. This is what writes the first heartbeat — there is no shortcut past it, because a heartbeat that no round produced is exactly the lie this design removes. When that round ends without a heartbeat for any reason (`collect` exit 4, 5 or 6, an empty `hash=`, a failed write of the monitor page, a directory-entry failure other than exit 3, or `finish` exit 2, 4 or 5), the start has failed: report the round's outcome and the code, do not run step 2, and do not claim a started assistant. A round that completed with failed items (`collect` exit 1 or 3) is still a completed round — carry on to step 2 and name the failures in the closing report. Completion condition: `patrol.sh finish` exited 0, or the failure report naming the step and the code has been printed and no start was claimed.
|
||||
1. **Reconcile the built-in check items against the delegation list.** Run `{CURRENT}/jsc-assist/tools/seed-tasks.sh apply --root {CURRENT}`. This comes before the round, so the round's own task-book section already shows the items this machine is supposed to be checking. Judge the result by the seed-tasks.sh exit-code table, and keep every `add=`, `remove=`, `drift=`, `held=`, `bad=`, `dup=`, `skip_user=`, `probe=`, `pending=` and `probe_bad=` line plus the summary counts for the report. **Exit 1, 2 and 3 do not stop the start.** Nothing was touched in any of those cases, so the assistant still has whatever items it had before and the round is still worth running: record what the code means, put it into the closing report, and carry on to step 2. Exit 4 is the same — the entries that did get added or removed stand, and the failed ones are named. Never pass `--force` and never pass `--allow-cond` on your own initiative: the first would let this step delete something a person asked for, and the second asserts a condition only a person can check. Completion condition: the exit code and the summary counts are recorded, with every `held=` row's skill name kept for the report, or the code was recorded as "nothing was touched" and step 2 was reached anyway.
|
||||
|
||||
2. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported.
|
||||
2. **Run one patrol round.** Follow every step of the `patrol` operation below, start to finish. This is what writes the first heartbeat — there is no shortcut past it, because a heartbeat that no round produced is exactly the lie this design removes. When that round ends without a heartbeat for any reason (`collect` exit 4, 5 or 6, an empty `hash=`, a failed write of the monitor page, a directory-entry failure other than exit 3, or `finish` exit 2, 4 or 5), the start has failed: report the round's outcome and the code, do not run step 4, and do not claim a started assistant. A round that completed with failed items (`collect` exit 1 or 3) is still a completed round — carry on to step 3 and name the failures in the closing report. Completion condition: `patrol.sh finish` exited 0, or the failure report naming the step and the code has been printed and no start was claimed.
|
||||
|
||||
3. **Install the patrol entry.** Run `{CURRENT}/jsc-assist/tools/schedule.sh install patrol`. Judge the result by the schedule.sh exit-code table, and keep the printed `entry=`, `ttl=`, `period=`, `legacy_removed=`, `others_kept=`, `env_snapshot=`, `patrol_root=`, every `allow_rule=` line and `service=` for the report. Exit 1 is the case to get right: the entry is installed and inert, so step 4 reports a started assistant whose heartbeat will expire, not a scheduled one. Exit 6 with a CLI executable that is not on `PATH` is the second one: nothing was installed, and the fix is to install that CLI or to pass `--patrol-cmd`, not to write a bare command name into the entry. On 2, 3, 4, 5 or 6 nothing is scheduled — report the code, say the round ran but no further round will, and do not claim the assistant will stay alive. Completion condition: the exit code is recorded, and on exit 0 the entry line, the TTL, the period, the legacy count, the surviving-entry count, the snapshotted variable names, the tool root the entry carries and the allow rules are recorded with it.
|
||||
3. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported.
|
||||
|
||||
4. **Report the start.** Print the round's verdict and its four item results, the monitor page that was written, the heartbeat path, the local time of `ts`, the TTL in seconds, `pid`, `cli` and `session` as hints, then the scheduler mechanism, the derived period, the installed entry line as the script printed it with the token already masked, the `patrol_root=` the entry carries — that is what every later round reads its tool root from — how many legacy heartbeat entries were removed, and how many other entries were left untouched. Then hand over the two operator items the install printed: the `allow_rule=` lines verbatim, so the unattended round never meets a permission prompt, and the reminder that the entry holds a snapshot of the listed variables including the token — keep the crontab file readable by its owner alone, and run `install` again after any of those variables changes. Close with the notice that matches step 3's outcome, printed literally with `{ttl}` replaced by the TTL just read and `{period}` by the derived period:
|
||||
4. **Install the patrol entry.** Run `{CURRENT}/jsc-assist/tools/schedule.sh install patrol`. Judge the result by the schedule.sh exit-code table, and keep the printed `entry=`, `entry_len=`, `ttl=`, `period=`, `legacy_removed=`, `others_kept=`, `env_snapshot=`, `patrol_root=`, every `allow_rule=` line and `service=` for the report. `entry_len=` is measured against cron's per-line limit in both the real install and `--dry-run`, because that limit is enforced by `crontab` itself: a snapshot that pushes the entry over it fails the install with a bare "command too long" while every dry run stays green. Exit 1 is the case to get right: the entry is installed and inert, so step 5 reports a started assistant whose heartbeat will expire, not a scheduled one. Exit 6 with a CLI executable that is not on `PATH` is the second one: nothing was installed, and the fix is to install that CLI or to pass `--patrol-cmd`, not to write a bare command name into the entry. On 2, 3, 4, 5 or 6 nothing is scheduled — report the code, say the round ran but no further round will, and do not claim the assistant will stay alive. Completion condition: the exit code is recorded, and on exit 0 the entry line, the TTL, the period, the legacy count, the surviving-entry count, the snapshotted variable names, the tool root the entry carries and the allow rules are recorded with it.
|
||||
|
||||
| Step 3 | Notice |
|
||||
5. **Report the start.** Print the round's verdict and its four item results, the monitor page that was written, the heartbeat path, the local time of `ts`, the TTL in seconds, `pid`, `cli` and `session` as hints, then the scheduler mechanism, the derived period, the installed entry line as the script printed it with the token already masked, the `patrol_root=` the entry carries — that is what every later round reads its tool root from — how many legacy heartbeat entries were removed, and how many other entries were left untouched. Then hand over the two operator items the install printed: the `allow_rule=` lines verbatim, so the unattended round never meets a permission prompt, and the reminder that the entry holds a snapshot of the listed variables including the token — keep the crontab file readable by its owner alone, and run `install` again after any of those variables changes.
|
||||
|
||||
**Then report step 1's reconcile in its own block**, because it is the only place the built-in items are accounted for: how many were added, how many removed, how many left alone, then every held `cond` row by name with the reason it was held, every `bad=` row as a defect in the list rather than in this machine, every `drift=` row with the change the list now asks for and the note that `--refresh` is what applies it, and every `dup=` or `skip_user=` row as an entry a person has to settle. **Then account for the `probe` column separately**: every `probe=` row as an item that now runs a read-only command rather than only reminding, naming any `{cli}` or `{repo}` hole still in it; every `pending=` row as a slice whose entry point is not wired yet, with the list's own reason — that is the only place those are distinguishable from the rows that were always reminders; and every `probe_bad=` row as an item that fell back to reminding, with what stopped the substitution. When the summary carries `spec_cols=11`, say that this machine's `jsc-meta` predates the column and that no item runs a command this round. An exit of 1, 2 or 3 is reported here as "the built-in items were left as they were" with the reason, never as "there is nothing to check". Close with the notice that matches step 4's outcome, printed literally with `{ttl}` replaced by the TTL just read and `{period}` by the derived period:
|
||||
|
||||
| Step 4 | Notice |
|
||||
| --- | --- |
|
||||
| exit 0 | 助理已啟動,第一輪巡檢跑完了,結果寫上監控頁了,心跳也寫了。排程接上了,之後每 {period} 分鐘跑一輪,每一輪跑完才寫一次心跳。心跳新鮮代表上一輪巡檢真的做完了;那一輪各項有沒有全過,看監控頁的本輪判定。 |
|
||||
| exit 1 | 助理已啟動,第一輪巡檢跑完了,排程條目也寫進去了,但 cron 服務沒在跑,那一筆一次都不會被執行。心跳過了 {ttl} 秒就會過期。請先跑 `sudo service cron start`,重開 WSL 之後要再跑一次。 |
|
||||
| 其他結束碼 | 助理已啟動,第一輪巡檢跑完了,但排程沒接上(結束碼 {code})。不會再有下一輪,心跳過了 {ttl} 秒就會過期,屆時請再跑一次 start。 |
|
||||
|
||||
Completion condition: the report carries the round verdict, the monitor page name, the path, the local heartbeat time, the TTL, the period, the three hint fields, the scheduler outcome, the tool root the entry carries, the allow rules and the snapshot reminder, and exactly one notice above appears with the real numbers.
|
||||
Completion condition: the report carries the round verdict, the monitor page name, the path, the local heartbeat time, the TTL, the period, the three hint fields, the scheduler outcome, the tool root the entry carries, the allow rules and the snapshot reminder; the reconcile block carries the three counts and one line per held, drifted, rejected or duplicated row; and exactly one notice above appears with the real numbers.
|
||||
|
||||
## patrol
|
||||
|
||||
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=` and `contents_file=`. 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=`, `reminders=`, `reminders_builtin=`, 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.
|
||||
|
||||
**The round also writes the reminder queue.** `collect` leaves `$JSC_HOME/assistant/reminders.tsv` behind — a tab-separated file whose first line is `round`, the round id, the round's UTC timestamp, the failing count and the same moment in epoch seconds — the ISO string is for a person to read and the epoch is what a reader does arithmetic on, so no reader has to carry its own date parser — followed by one row per item to surface, each carrying its kind, the id, the reason or the how-long-overdue wording, and the title. There are three kinds, and the split is the point: `overdue` for an entry past its deadline, `remind` for a reminder-type entry a person registered, and `builtin` for one the delegation list seeded. **A built-in reminder waits on plumbing, not on a person** — its action is `remind`, nothing executes it yet, so it comes due every round and reads identically every round; eight of them on this machine turned every session's opening into eight lines nobody could act on. Whatever prints the queue names the first two kinds and gives the third a single count line, which is why the kind is decided here, where `spec_key` is readable, and not there. The counts are printed as `reminders=` for the named kinds and `reminders_builtin=` for the third, alongside `reminders_file=`. **The judgement stays here and the queue is only its output**: whatever reads it later prints and nothing more, because a reader that compared `due` and `next_run` against the clock itself would be a second judgement of the same thing, and the day the two disagreed both would look right. The timestamp on that first line is what the design turns on — a queue nobody refreshed reads exactly like a fresh one, so the reader has to be able to say how old it is. **"No reminders" and "nobody computed the reminders" must never look the same.**
|
||||
|
||||
**`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.
|
||||
|
||||
@@ -278,15 +362,21 @@ One round: read five sources, record the result, then beat. Everything before th
|
||||
|
||||
3. **Rebuild `MONITOR_{HASH}` from its three blocks.** Hand it to `jsc-gitea:wiki` with page type `MONITOR`, so the repo is the one `gitea.sh wiki-repo MONITOR` resolves — `JSC_WIKI_REPO_MONITOR`, then `JSC_WIKI_REPO`, then exit 3. This is the content page and it stays in that repo; only the directory page of step 4 moved to the `CONTENTS` repo, and neither chain ever falls back to the other. Read the page back first, then build the new body out of what came back plus this round's files, in this order and with nothing else on the page:
|
||||
|
||||
**The three blocks are joined by `{CURRENT}/jsc-assist/tools/patrol.sh rebuild --old {the file the old page was read into}`, never by hand.** It prints `rebuilt_file=` — put that file. It also prints `old_format=`, `blocks=`, `summary_rows=` and `old_rows_kept=`; carry those into the report. Exit 6 means an argument was rejected or the old-page file is not there, exit 5 a scratch file could not be written; both take step 3's abort row.
|
||||
|
||||
Joining them by hand is what this replaces, and the reason is worth keeping. The join is a fixed shape, so leaving it to the caller meant a fresh agent picked its own tools for it every round: one round reached for `awk`, was refused by the permission gate twice, worked around it because somebody was there to watch, and said in its own report that the same refusal would hang an unattended round — with the round lock already taken. The scratch directory had accumulated 124 files by then, named `newpage.md`, `new-page.md`, `new_page.md`, `page-new.md` and so on: the same join, redone every round, under a different set of names. **A step with one correct shape does not belong in a prompt.** What the script joins:
|
||||
|
||||
| Block | Where it comes from |
|
||||
| --- | --- |
|
||||
| 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived |
|
||||
| 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived — the script does not even normalise its trailing blank lines |
|
||||
| 最新一輪 | the whole content of `latest_file`, replacing the old block entirely |
|
||||
| 近 24 輪摘要 | `summary_file`, which already holds the heading, the five-column table header (`巡檢時間`、`本輪判定`、`各項成敗`、`待人處理`、`警示來源`) and this round's row; then the old table's data rows in their old order underneath, cut so the table holds at most 24 rows |
|
||||
|
||||
An old-format page — per-round sections stacked up, no 最新一輪 heading — is detected by the script itself, which keeps the basic-data block, drops the stacked sections, starts the table with this round's row, and prints `old_format=yes converted=1`. Report that the page was converted.
|
||||
|
||||
**Verify the page's links before the write.** List every link the rebuilt body carries — the ones the latest-round block brought in, and any that survived in the block carried over from the old page — and run `{CURRENT}/jsc-gitea/tools/link-check.sh` over the whole list. Exit 0 is the only result that permits the write. On exit 1 report the `DEAD` lines verbatim, then run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}` and stop: a round that writes a dead link records a false trail nobody can follow back. Exits 2, 3 and 7 take the same abort, each reported by the rule B table above. A body carrying no link at all needs no call — say so in the report rather than claiming a check that never ran.
|
||||
|
||||
Put the whole page. An old-format page — per-round sections stacked up, no summary table — has no rows to carry over: keep its `本頁基本資料` block, drop the stacked sections, let the table start with this round's row, and say in the report that the page was converted. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **No record, no heartbeat**, and that verdict belongs to this step alone: the round's result lives on this page, so a repo this step cannot resolve leaves the round with nowhere to be recorded. Step 4 is judged on its own terms. Completion condition: `link-check.sh` exited 0 over the body's links or the body carried none, the put or the create returned success, and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code.
|
||||
Put the whole page. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **When the write did not fail but was refused — the permission layer stopped the command before it ran — the report MUST carry the command line verbatim, exactly as it was submitted.** A refusal has no exit code and no stderr from the tool, so the command text is the only thing that can be held against the allow list, and the allow list matches the text as written. A report that says only 「the write was refused」 cannot be acted on: this happened, and the round that hit it named neither the command nor the tool, so every obvious suspect had to be ruled out one at a time and the actual one was never found. Quote the whole line — the environment-variable prefix included, since that counts as part of what the rule is matched against — and say which step submitted it. **No record, no heartbeat**, and that verdict belongs to this step alone: the round's result lives on this page, so a repo this step cannot resolve leaves the round with nowhere to be recorded. Step 4 is judged on its own terms. Completion condition: `link-check.sh` exited 0 over the body's links or the body carried none, the put or the create returned success, and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code.
|
||||
|
||||
4. **Update this machine's block in `MONITOR_CONTENTS`, through `jsc-gitea/tools/wiki-contents.sh`.** That page is a directory every machine writes to, and it lives in the repo `gitea.sh wiki-repo CONTENTS` resolves — `JSC_WIKI_REPO_CONTENTS`, then `JSC_WIKI_REPO`, then exit 3, and never a fallback to `JSC_WIKI_REPO_MONITOR`. The page carries no table: it is an H1, a `>` preamble, and then one H2 block per machine — the heading is that machine's monitor page name, and the fields are one `- {name}:{value}` bullet each underneath. The script owns the read-match-write of one block, so never read this page and rebuild it by hand, never write it through `jsc-gitea:wiki`, and never rebuild it the way step 3 rebuilds the content page — every other block here belongs to a machine that is not this one, and one careless whole-page write deletes their records.
|
||||
|
||||
@@ -314,17 +404,19 @@ One round: read five sources, record the result, then beat. Everything before th
|
||||
|
||||
Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran.
|
||||
|
||||
5. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code.
|
||||
5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **`repos=` and `repo_scan=` are the same kind of reading for `{repo}`.** `repos=0` with `repo_scan=rc3` means the scan had no starting point, so every entry carrying `{repo}` was held this round — report the variable to set, not "there is nothing to inventory". `repo_scan=rc1` means the scan handed over what it could and named the rest: each `repo_scan_note=` line is a directory the scan looked at and did not hand over whole: a path carrying a space or a shell metacharacter, a repository with no `origin` to compute a `REPO_{HASH}` page name from, or a `not_a_repo=` — a directory holding a `.git` that git itself refuses, which is a leftover rather than a repository waiting to be wired. Those lines go into the report as findings; a round that prints only the count reads as a full sweep. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was.
|
||||
|
||||
6. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL.
|
||||
6. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code.
|
||||
|
||||
7. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL.
|
||||
|
||||
**The event numbers get their own line, and the unpaired starts get their own list.** Print `events_total=` and `events_bad=` as this round's event count and its non-`ok` count, then every non-`ok` event with its `kind`, `name`, `status`, `exit` and `detail`, then — separately, never folded into the same list — every start with no matching end, by `name` and `session`. A non-zero `events_unpaired=` is the round's most important finding: each row is a skill run that started and never reached its closing step. Say `events_rotated=` too when it is `rotated` or `failed`. When `item=D-11` failed, say the source could not be read rather than reporting zero events — zero read events and zero existing events look identical in a report and mean opposite things.
|
||||
|
||||
Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on.
|
||||
**Then report step 5 in its own block**, because it is the only place the task book's own work is accounted for: how many entries were due, how many ran, how many came back clean and how many failed, then every `failed=` row by name with the first line of its command's output, every `held=` row with what could not be given a target, and every `write_failed=` row as an entry that ran without being recorded. Say plainly that a held entry's history was left untouched. Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on.
|
||||
|
||||
## status
|
||||
|
||||
Read-only throughout. This operation creates, modifies and deletes nothing under `$JSC_HOME`, and it never calls `write` or `clear`.
|
||||
Read-only throughout. This operation creates, modifies and deletes nothing under `$JSC_HOME`, and it never calls `write` or `clear`. It compares the built-in items against the delegation list with `seed-tasks.sh plan`, never with `apply`.
|
||||
|
||||
1. **Read the heartbeat through the script.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and split the line on spaces, taking `file=` last so a path containing spaces stays intact. Map `state=` to the verdict: `fresh` → `新鮮`, `stale` → `過期`, `invalid` → `心跳檔損壞`, `absent` → `不存在`. Print `助理未運行` for `stale`, `invalid` and `absent`. Never re-derive the verdict from `ts` yourself, and never treat `invalid` as fresh. On exit 2 or 6, follow that code's row, record the heartbeat state as unknown, and carry on to step 2 — the task book is still worth printing. Completion condition: the heartbeat state holds one of `新鮮`, `過期`, `心跳檔損壞`, `不存在` or unknown, and `ts`, `age`, `ttl`, `pid`, `cli`, `session` and `file` are recorded as read or as empty.
|
||||
|
||||
@@ -354,9 +446,11 @@ 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. **Finish successfully.** `助理未運行`, an absent `tasks/` directory, an empty `tasks/` directory and an uninstalled schedule are normal results — never exit non-zero for any of them. Reserve a failure report for a condition none of the tables above covers, and state which path and which error produced it. Completion condition: the report is printed and nothing under `$JSC_HOME` has been created, modified or deleted.
|
||||
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.
|
||||
|
||||
8. **Finish successfully.** `助理未運行`, an absent `tasks/` directory, an empty `tasks/` directory and an uninstalled schedule are normal results — never exit non-zero for any of them. Reserve a failure report for a condition none of the tables above covers, and state which path and which error produced it. Completion condition: the report is printed and nothing under `$JSC_HOME` has been created, modified or deleted.
|
||||
|
||||
## stop
|
||||
|
||||
|
||||
+163
-23
@@ -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,19 @@ 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 human=%s title=%s\n' \
|
||||
"$_oi" "$_os" "$_od" "$_oa" "$(dur_human "$_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 +1153,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 +1287,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 +1295,7 @@ shift
|
||||
case "$CMD" in
|
||||
scan) cmd_scan "$@" ;;
|
||||
events) cmd_events "$@" ;;
|
||||
overdue) cmd_overdue "$@" ;;
|
||||
next) cmd_next "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
+193
-4
@@ -148,6 +148,9 @@
|
||||
# warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示
|
||||
# 時,原因只寫在這裡
|
||||
# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用
|
||||
# tasks_overdue= 逾期筆數(截止時間已經過了),取自到期判定那一支;判不出來時是減號
|
||||
# reminders= reminders_builtin= reminders_file= 提醒佇列逐筆點名的筆數、只算總數的內建項
|
||||
# 筆數,與佇列路徑。工作階段開始那一支 hook 讀它
|
||||
# tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空
|
||||
# events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空
|
||||
# due_status= due_rc= 到期判定那一支的結果與結束碼
|
||||
@@ -205,9 +208,15 @@ WARN=0
|
||||
DUE_STATUS=fail
|
||||
DUE_RC=0
|
||||
DUE_MD=''
|
||||
DUE_ROWS=''
|
||||
DUE_NOTE=''
|
||||
DUE_TASKS=''
|
||||
DUE_EVENTS=''
|
||||
DUE_OVERDUE=''
|
||||
FAILING_LINES=''
|
||||
REMINDERS=0
|
||||
REMINDERS_BUILTIN=0
|
||||
REMINDERS_FILE=''
|
||||
|
||||
# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。
|
||||
# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉;
|
||||
@@ -237,6 +246,7 @@ usage() {
|
||||
usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動]
|
||||
patrol.sh finish --round 輪次代號 [--out 目錄] [--dry-run]
|
||||
patrol.sh abort --round 輪次代號 [--out 目錄]
|
||||
patrol.sh rebuild --old 舊頁檔 [--out 目錄]
|
||||
EOF
|
||||
exit 6
|
||||
}
|
||||
@@ -873,7 +883,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
|
||||
@@ -881,11 +891,104 @@ 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
|
||||
}
|
||||
|
||||
# --- 提醒佇列 ---
|
||||
#
|
||||
# 一輪算完之後,把「要送到人面前」的那幾筆寫成一份佇列檔,位置固定在助理狀態目錄底下。
|
||||
# 讀的那一端是工作階段開始那一支 hook:它只印,一個判定都不做。
|
||||
#
|
||||
# 為什麼判定不放在 hook 那一邊:hook 跑在每一個工作階段的開頭,它要快、而且絕對不能擋人。
|
||||
# 更重要的是,讓 hook 自己拿 due 欄與 next_run 去比就是第二套到期判定,跟 due.sh 那一套
|
||||
# 會漂移,而漂移的那一天兩邊都說自己是對的。所以判定只有一套,佇列是它的輸出。
|
||||
#
|
||||
# 這樣換來一個新的失效模式,要正面處理:助理沒在跑的時候,這份佇列不會更新,而一份舊佇列
|
||||
# 讀起來跟新的一模一樣。所以檔頭寫一行 round,帶著這一輪的時間戳,讓讀的那一端算得出
|
||||
# 它有多舊——「沒有提醒」與「沒有人算提醒」不可以長得一樣。
|
||||
write_reminders() {
|
||||
REMINDERS=0
|
||||
REMINDERS_FILE="$STATE_DIR/reminders.tsv"
|
||||
_rt="$REMINDERS_FILE.tmp.$$"
|
||||
{
|
||||
# 檔頭同時寫 ISO 時間與 epoch 秒。ISO 給人看,epoch 給讀的那一端算年紀——讓它自己解
|
||||
# ISO 字串就是在每一個讀取端各放一份日期解析,而 date -d 不是每一台機器都認得那個格式。
|
||||
# 最後那個待辦總筆數是給讀的那一端判「該不該吵」用的:佇列空又過期時,待辦簿有東西
|
||||
# 才代表「有事沒人在算」,零筆就只是助理閒著,那時候安靜才對。
|
||||
printf 'round\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$ROUND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TASKS_FAILING" "$(date +%s)" "$TASKS_TOTAL"
|
||||
# 逾期那幾筆照抄判定那一支印的行,連「逾期多久」那個給人看的寫法都取它算好的:
|
||||
# 自己再寫一個時間長度格式化,同一個秒數在兩個地方就會印出兩種說法。
|
||||
_ovids="$RD/overdue-ids"
|
||||
: >"$_ovids"
|
||||
if [ -f "$RD/due.out" ]; then
|
||||
sed -n 's/^overdue_row=//p' "$RD/due.out" 2>/dev/null | while IFS= read -r _l; do
|
||||
[ -n "$_l" ] || continue
|
||||
_oi=${_l%% *}
|
||||
_oh=$(printf '%s' "$_l" | sed -n 's/.* human=\(.*\) title=.*/\1/p')
|
||||
_ot=$(printf '%s' "$_l" | sed -n 's/.* title=//p')
|
||||
printf '%s\n' "$_oi" >>"$_ovids"
|
||||
printf 'overdue\t%s\t%s\t%s\n' "$_oi" "${_oh:--}" "${_ot:--}"
|
||||
done
|
||||
fi
|
||||
# 內建項的識別碼,一行一個。分兩種列要靠它:帶 spec_key 的那幾筆是依委派清單種入的,
|
||||
# 使用者一筆都沒交辦過。
|
||||
_specids="$RD/spec-ids"
|
||||
: >"$_specids"
|
||||
for _tf in "$STATE_DIR/tasks"/*; do
|
||||
[ -f "$_tf" ] || continue
|
||||
_sk=$(sed -n 's/^spec_key=//p' "$_tf" 2>/dev/null | head -n1)
|
||||
[ -n "$_sk" ] || continue
|
||||
_tid=$(sed -n 's/^id=//p' "$_tf" 2>/dev/null | head -n1)
|
||||
[ -n "$_tid" ] || _tid=$(basename -- "$_tf")
|
||||
printf '%s\n' "$_tid" >>"$_specids"
|
||||
done
|
||||
# 只提醒型而且到期的那幾筆。指令型不進佇列:它們由執行那一支真的跑掉了,人不必接手。
|
||||
# 已經以逾期身分列過的那幾筆不再列第二次:同一筆待辦在同一批提醒裡出現兩行,讀的人會
|
||||
# 當成兩件事,而且「逾期五天」比「排定點過了」講得更清楚——留強的那一行就好。
|
||||
#
|
||||
# 內建項與使用者交辦的分成兩種列,理由是它們該有的處置不一樣。
|
||||
# 使用者自己登錄的那一筆,人看到就做得了;內建項那幾筆等的是接線不是人——動作是
|
||||
# 「只提醒」的內建項,到現在還沒有任何執行入口,所以每一輪都到期、每一輪都一樣。
|
||||
# 實測踩到:這台機器八筆全是那一種,於是每一個工作階段開頭固定吐八行一模一樣的東西。
|
||||
# 那不是提醒,是噪音;而讀的那一端只印不判,分不出兩者,所以在這裡就把種類標好。
|
||||
if [ -n "$DUE_ROWS" ] && [ -f "$DUE_ROWS" ]; then
|
||||
awk -F'\t' -v idf="$_ovids" -v sidf="$_specids" '
|
||||
BEGIN {
|
||||
while ((getline _l < idf) > 0) seen[_l] = 1
|
||||
while ((getline _s < sidf) > 0) spec[_s] = 1
|
||||
}
|
||||
NF >= 12 && $1 != "" && $2 == "due" && $5 == "remind" && !($1 in seen) {
|
||||
printf "%s\t%s\t%s\t%s\n", ($1 in spec ? "builtin" : "remind"), \
|
||||
$1, ($10 == "" ? "-" : $10), ($11 == "" ? "-" : $11)
|
||||
}' "$DUE_ROWS" 2>/dev/null
|
||||
fi
|
||||
} >"$_rt" 2>/dev/null || { add_warn '提醒佇列寫不出來'; rm -f "$_rt"; return 0; }
|
||||
if mv "$_rt" "$REMINDERS_FILE" 2>/dev/null; then
|
||||
# 兩個數字分開算:逐筆點名的那幾筆,與只算一個總數的內建項。
|
||||
REMINDERS=$(awk -F'\t' '$1 == "remind" || $1 == "overdue" { n++ } END { print n + 0 }' "$REMINDERS_FILE")
|
||||
REMINDERS_BUILTIN=$(awk -F'\t' '$1 == "builtin" { n++ } END { print n + 0 }' "$REMINDERS_FILE")
|
||||
else
|
||||
add_warn '提醒佇列換不上去'
|
||||
rm -f "$_rt"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 待辦簿的事件偵測與到期判定 ---
|
||||
#
|
||||
# 算到期的邏輯不在這一支,也不在 tools/tasks.sh,而在 tools/due.sh,理由寫在那一支的檔頭:
|
||||
@@ -911,8 +1014,14 @@ due_scan() {
|
||||
JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" </dev/null || _rc=$?
|
||||
DUE_RC="$_rc"
|
||||
DUE_MD=$(sed -n 's/^due_file=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
||||
# 機器可讀的那一份也要轉出去。判到期與執行是兩支腳本,中間靠這個檔案交棒;不轉出去
|
||||
# 呼叫端就只能靠預設路徑猜,而那個預設指向的是上一次有人手動跑 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)
|
||||
@@ -990,8 +1099,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' \
|
||||
@@ -1078,7 +1198,7 @@ compose() {
|
||||
CMD="${1:-}"
|
||||
[ -n "$CMD" ] || usage
|
||||
shift
|
||||
case "$CMD" in collect|finish|abort) ;; *) usage ;; esac
|
||||
case "$CMD" in collect|finish|abort|rebuild) ;; *) usage ;; esac
|
||||
|
||||
OUT=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
@@ -1087,9 +1207,11 @@ while [ "$#" -gt 0 ]; do
|
||||
--trigger) [ "$#" -ge 2 ] || usage; TRIGGER="$2"; shift 2 ;;
|
||||
--round) [ "$#" -ge 2 ] || usage; ROUND="$2"; shift 2 ;;
|
||||
--dry-run) DRYRUN=1; shift ;;
|
||||
--old) [ "$#" -ge 2 ] || usage; OLD_PAGE="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
OLD_PAGE="${OLD_PAGE:-}"
|
||||
[ -n "$OUT" ] && RD="$OUT"
|
||||
|
||||
case "$CMD" in
|
||||
@@ -1120,6 +1242,18 @@ 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
|
||||
write_reminders
|
||||
tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS"
|
||||
tally "$D11_STATUS"
|
||||
|
||||
@@ -1150,10 +1284,15 @@ 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 'reminders=%s\n' "$REMINDERS"
|
||||
printf 'reminders_builtin=%s\n' "$REMINDERS_BUILTIN"
|
||||
printf 'reminders_file=%s\n' "$REMINDERS_FILE"
|
||||
printf 'events_new=%s\n' "$DUE_EVENTS"
|
||||
printf 'due_status=%s\n' "$DUE_STATUS"
|
||||
printf 'due_rc=%s\n' "$DUE_RC"
|
||||
printf 'due_file=%s\n' "$DUE_MD"
|
||||
printf 'due_rows_file=%s\n' "${DUE_ROWS:-}"
|
||||
printf 'pending=%s\n' "$PEND_COUNT"
|
||||
printf 'events_total=%s\n' "$EV_TOTAL"
|
||||
printf 'events_bad=%s\n' "$EV_BAD"
|
||||
@@ -1204,4 +1343,54 @@ case "$CMD" in
|
||||
printf 'heartbeat=not-written\n'
|
||||
lock_release
|
||||
exit 0 ;;
|
||||
|
||||
# 拿舊頁重組出要寫回去的整頁。三塊的接法固定,所以由這一支做完,呼叫端只要「讀回舊頁、
|
||||
# 叫這一支、把結果寫回去」三步。
|
||||
#
|
||||
# 為什麼要有這個子命令:接三塊這件事本來留給呼叫端,而呼叫端是一個代理人,於是它每一輪
|
||||
# 臨場挑工具——實測某一輪挑了 awk,兩次都被權限擋掉;那一輪有人在場所以它改用別的方式繞
|
||||
# 過去了,並且自己指出無人值守那一輪遇到同一個擋法會直接卡住,而鎖在第一步就取走了。
|
||||
# 更根本的是暫存目錄實測累積了 124 個檔案,名字是 newpage.md、new-page.md、new_page.md、
|
||||
# page-new.md 這種——同一件事每輪重做一次,每輪換一組檔名。規格說那裡只該有五個檔案。
|
||||
# 接法寫死在這裡之後,那一步不再需要任何通用文字工具,也就沒有東西可以被擋。
|
||||
rebuild)
|
||||
[ -n "$OLD_PAGE" ] || usage
|
||||
[ -f "$OLD_PAGE" ] || die 6 "舊頁檔讀不到:$OLD_PAGE。這一支不自己讀 wiki,舊頁要由呼叫端先取下來。"
|
||||
for _f in "$RD/latest.md" "$RD/summary.md"; do
|
||||
[ -f "$_f" ] || die 6 "$_f 不在。rebuild 要接的是同一輪 collect 產出的那幾塊,先跑 collect。"
|
||||
done
|
||||
_out="$RD/rebuilt.md"
|
||||
# 舊頁那一塊「本頁基本資料」原樣留著,從它的標題到「最新一輪」前一行為止。整頁沒有
|
||||
# 「最新一輪」那個標題時視為舊格式:基本資料留著、堆疊的各輪小節丟掉,摘要表從本輪開始。
|
||||
if grep -q '^## 最新一輪' "$OLD_PAGE" 2>/dev/null; then
|
||||
awk '/^## 最新一輪/{exit} {print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。"
|
||||
printf 'old_format=no\n'
|
||||
else
|
||||
awk '/^## /{n++} n<=1{print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。"
|
||||
printf 'old_format=yes converted=1\n'
|
||||
fi
|
||||
# 舊表的資料列照原順序留在本輪那一列底下,整張表截到 24 列。摘要那一塊已經含標題、
|
||||
# 表頭與本輪那一列,所以這裡只補舊列。
|
||||
awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{print}' "$OLD_PAGE" >"$RD/rb-rows.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-rows.md。"
|
||||
_new_rows=$(awk '/^\| 2/{n++} END{print n+0}' "$RD/summary.md")
|
||||
_keep=$((24 - _new_rows))
|
||||
[ "$_keep" -lt 0 ] && _keep=0
|
||||
# 基本資料那一塊逐字照抄,連尾端空行都不動——技能本文寫的是「逐字保留」,剝掉再補一個
|
||||
# 雖然內容沒少,那句話就變成假的了。只在它沒有以空行收尾時補一個分隔,免得下一塊的
|
||||
# 標題黏在表格最後一列後面。
|
||||
_tail=$(awk 'END{print (NF==0 ? "blank" : "text")}' "$RD/rb-head.md" 2>/dev/null)
|
||||
{
|
||||
cat "$RD/rb-head.md"
|
||||
[ "$_tail" = "blank" ] || printf '\n'
|
||||
cat "$RD/latest.md"
|
||||
printf '\n'
|
||||
cat "$RD/summary.md"
|
||||
[ "$_keep" -gt 0 ] && head -n "$_keep" "$RD/rb-rows.md"
|
||||
} >"$_out" 2>/dev/null || die 5 "暫存檔寫不進去:$_out。"
|
||||
printf 'rebuilt_file=%s\n' "$_out"
|
||||
printf 'blocks=%s summary_rows=%s old_rows_kept=%s\n' \
|
||||
"$(grep -c '^## ' "$_out")" \
|
||||
"$(awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{n++} END{print n+0}' "$_out")" \
|
||||
"$([ "$_keep" -gt 0 ] && awk 'END{print NR+0}' "$RD/rb-rows.md" | awk -v k="$_keep" '{print ($1<k?$1:k)}' || echo 0)"
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
Executable
+475
@@ -0,0 +1,475 @@
|
||||
#!/usr/bin/env sh
|
||||
# run-due.sh — 把到期的內建檢查項真的跑一遍,成敗回寫待辦簿。
|
||||
#
|
||||
# 用法:
|
||||
# run-due.sh run [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}] [--dry-run]
|
||||
# run-due.sh plan [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}]
|
||||
#
|
||||
# run 逐筆執行並回寫待辦簿。--dry-run 只印要跑什麼、不執行也不回寫
|
||||
# plan 等同 run --dry-run,另取一個名字是為了讓唯讀那一路在指令列上看得出來
|
||||
#
|
||||
# 結束碼:
|
||||
# 0 這一輪跑完了。零筆到期、每一筆都代不出目標,都算跑完
|
||||
# 1 至少一支指令回非零。那一筆已經記成失敗、失敗次數加一,其餘各筆照跑
|
||||
# 2 到期清單讀不到或欄位對不上,**一筆都沒跑**。判到期那一支沒跑過,或它的輸出換了格式
|
||||
# 4 至少一筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆
|
||||
# 5 檔案系統失敗:暫存檔寫不進去
|
||||
# 6 用法錯誤:不認得的子命令或選項、選項缺值、--root 不是絕對路徑
|
||||
# 同時命中好幾碼時,回報順序是 5、2、4、1:前面的蓋掉後面的。4 排在 1 前面是因為
|
||||
# 「跑了但沒記到」會讓同一件事每輪重跑,比「跑了而且失敗」更需要人知道。
|
||||
#
|
||||
# --- 這一支只跑指令型,不叫技能也不送提醒 ---
|
||||
#
|
||||
# 待辦簿的動作欄有三種值:一行指令、技能名、remind。這一支只跑第一種。
|
||||
# 技能名那一種不跑的理由:叫用一整支技能的代價與風險都大得多——它會寫 wiki、開 PR、改檔案,
|
||||
# 而這一支跑在沒有人看的那一輪裡。唯讀盤點指令失敗最多就是回一個非零碼,那一筆記一次失敗;
|
||||
# 一支技能中途失敗可能留下寫到一半的頁或開錯的 PR。兩種代價不同,就該分兩次判、分兩次接。
|
||||
# remind 那一種不跑的理由更直接:它本來就沒有東西可跑,提醒怎麼送到前景是另一件待辦。
|
||||
#
|
||||
# --- 只跑內建項,使用者交辦的一律不碰 ---
|
||||
#
|
||||
# 只有 spec_key 非空的那幾筆會被執行,也就是依委派清單種入的內建項。
|
||||
# 理由是那幾筆的指令經過種入那一支的檢核:路徑一定是 {root}/jsc-{domain}/ 開頭、不含金錢符號
|
||||
# 與波浪號、代入點只認得三個、腳本一定在這台機器上。使用者親手登錄的那幾筆沒有經過那道關,
|
||||
# 動作欄想寫什麼都行,在無人值守那一輪把它送進殼是另一回事,要另外判。
|
||||
# 執行這一刻再驗一次同樣那幾項,不因為種入時驗過就省掉:待辦檔是純文字,中間可能被改。
|
||||
#
|
||||
# --- 權限閘門與這一支的關係,講白 ---
|
||||
#
|
||||
# 這一支自己執行那些指令,所以權限層只看得到 run-due.sh 這一條指令,看不到裡面跑了什麼。
|
||||
# 那不是繞過閘門,是閘門本來就不是沙箱:它是一份「可信入口」的白名單,而清單上的每一支腳本
|
||||
# 本來就做得了它該做的事——巡檢那一支會寫 wiki 頁,部署那一支會改整台機器的外掛。
|
||||
# 防線改由三層擋:一、只跑 spec_key 非空的內建項,那幾筆的來源是版本控管、要走 PR 的委派清單;
|
||||
# 二、執行前再驗一次指令形狀,不信任待辦檔的內容;三、代不出來的目標一律不跑。
|
||||
# 這一段寫在這裡是因為下一個維護的人一定會問,而「為什麼不會被擋」跟「為什麼不需要被擋」
|
||||
# 是兩個不同的答案,後者才是對的那一個。
|
||||
#
|
||||
# --- 代不出目標不算失敗 ---
|
||||
#
|
||||
# {cli} 由偵測到的 CLI 代號代入,{repo} 由 scan-repos.sh 掃到的存取庫工作目錄代入。
|
||||
# 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run,
|
||||
# 也不加失敗次數**。
|
||||
# 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數
|
||||
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
|
||||
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
|
||||
#
|
||||
# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支
|
||||
# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到
|
||||
# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。
|
||||
set -u
|
||||
|
||||
usage() {
|
||||
echo 'usage: run-due.sh {run|plan} [--rows 檔案] [--root 絕對路徑] [--now epoch] [--dry-run]' >&2
|
||||
exit 6
|
||||
}
|
||||
|
||||
die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; }
|
||||
|
||||
# 取第一行、截到指定長度。cut 數的是位元組不是字元,中文字剛好被切成一半時尾巴就變成
|
||||
# 替代字元——實測踩到:一行中文錯誤訊息截在第 200 個位元組,報告上留下一個亂碼字。
|
||||
# 截完再過一次 iconv -c 把那個不完整的序列丟掉。iconv 不在這台機器上就退回原樣,
|
||||
# 不自己拼一套 UTF-8 邊界判定:那是在錯誤訊息這條路上加一個會出錯的新東西。
|
||||
clip() { # $1=位元組上限;讀標準輸入
|
||||
_raw=$(head -n1 | cut -c"1-$1")
|
||||
_cln=$(printf '%s' "$_raw" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null)
|
||||
if [ -n "$_cln" ]; then printf '%s' "$_cln"; else printf '%s' "$_raw"; fi
|
||||
}
|
||||
note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; }
|
||||
warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; }
|
||||
|
||||
[ "$#" -ge 1 ] || usage
|
||||
MODE=$1; shift
|
||||
case "$MODE" in
|
||||
run) DRYRUN=0 ;;
|
||||
plan) DRYRUN=1 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
ROWS=''
|
||||
OPT_ROOT=''
|
||||
NOW=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--rows) [ "$#" -ge 2 ] || usage; ROWS=$2; shift 2 ;;
|
||||
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
|
||||
--now) [ "$#" -ge 2 ] || usage; NOW=$2; shift 2 ;;
|
||||
--dry-run) DRYRUN=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$OPT_ROOT" in
|
||||
''|/*) ;;
|
||||
*) die 6 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
|
||||
esac
|
||||
|
||||
# 助理狀態目錄。判到期那一支把 rows.txt 放在這底下,兩支要對得上同一個位置。
|
||||
JSC_HOME_RESOLVED=${JSC_HOME:-${HOME:-}/.jsc}
|
||||
case "$JSC_HOME_RESOLVED" in
|
||||
/*) ;;
|
||||
*) die 6 'JSC_HOME 與 HOME 都解不出絕對路徑,找不到助理狀態目錄。' ;;
|
||||
esac
|
||||
# --rows 沒有預設值,一律要指定。
|
||||
#
|
||||
# 原本有預設,指向助理狀態目錄底下那一份。那個預設是這一支上線之後最嚴重的一個缺陷:
|
||||
# 判到期那一支是被巡檢用 --out 叫的,輸出寫進那一輪自己的暫存目錄,跟這個預設不是同一個
|
||||
# 位置。於是這一支每一輪讀的都是「上一次有人手動跑 scan 留下的那一份」。實測那份清單在
|
||||
# 機器上放了六小時,這一支每一輪都對它動手,還跑了一筆已經被移除的待辦。
|
||||
# 而它看起來完全正常——那份舊清單產生的當下是對的,內容剛好沒變。**錯了六小時才顯形。**
|
||||
#
|
||||
# 所以不留預設。少帶這個選項就回用法錯誤,吵一次總比安靜地對舊資料動手好。呼叫端要拿
|
||||
# collect 印的 due_rows_file= 餵進來,那是同一輪產生的那一份。
|
||||
if [ -z "$ROWS" ]; then
|
||||
die 6 '沒有帶 --rows。這一支不猜到期清單的位置:判到期那一支的輸出寫在叫它的人指定的目錄裡,猜一個預設就會讀到別人留下的舊檔案,而舊檔案讀起來跟新的一模一樣。請把 collect 印的 due_rows_file= 餵進來。'
|
||||
fi
|
||||
|
||||
[ -f "$ROWS" ] || die 2 "到期清單讀不到:$ROWS。請先跑 due.sh scan——沒跑過判定,跟「都沒到期」不是同一件事。"
|
||||
|
||||
TAB=$(printf '\t')
|
||||
TMPD=$(mktemp -d 2>/dev/null) || die 5 '暫存目錄建不起來。'
|
||||
trap 'rm -rf "$TMPD"' EXIT
|
||||
|
||||
[ -n "$NOW" ] || NOW=$(date -u +%s)
|
||||
NOW_ISO=$(date -u -d "@$NOW" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
|
||||
|| NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# --- 找同一組工具 ---
|
||||
|
||||
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT="$OPT_ROOT"
|
||||
# 沒帶 --root 就從自己的位置往上推兩層。呼叫端該餵進來,這只是開發時跑得動的退路。
|
||||
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
|
||||
|
||||
find_tool() { # $1=domain $2=相對路徑
|
||||
for _d in "jsc-$1" "$1"; do
|
||||
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/$2" ] && { printf '%s' "$ROOT/$_d/$2"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 清單太舊就拒跑。判到期與執行之間隔著呼叫端,那一段有可能斷掉——collect 失敗了而那一輪
|
||||
# 照樣往下走,或呼叫端餵進上一輪的路徑。兩種都會讓這一支對著一份不描述現況的清單動手,
|
||||
# 而那正是它上線之後六小時裡在做的事。
|
||||
# 門檻取心跳的過期門檻:那是這台機器認定「一輪跑完的紀錄還算新鮮」的長度,一份比它還舊的
|
||||
# 到期清單本來就不可能是這一輪產生的。讀不到門檻就退回 300 秒,跟心跳那一支的預設一致。
|
||||
_ttl=''
|
||||
if _hb=$(find_tool hooks hooks/heartbeat.sh 2>/dev/null); then
|
||||
_ttl=$("$_hb" report 2>/dev/null | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p' | head -n1)
|
||||
fi
|
||||
case "${_ttl:-}" in
|
||||
''|*[!0-9]*) _ttl=300 ;;
|
||||
esac
|
||||
_mtime=$(date -r "$ROWS" +%s 2>/dev/null) || _mtime=''
|
||||
if [ -n "$_mtime" ]; then
|
||||
_age=$((NOW - _mtime))
|
||||
if [ "$_age" -gt "$_ttl" ]; then
|
||||
die 2 "到期清單是 $_age 秒前產生的,超過門檻 $_ttl 秒,這一支不對它動手:$ROWS。一份比心跳門檻還舊的清單不可能描述這一輪,照著跑會動到已經不存在的待辦。請確認判到期那一步這一輪真的跑成功了,再把它印的路徑餵進來。"
|
||||
fi
|
||||
fi
|
||||
|
||||
TASKS_SH="$HERE/tasks.sh"
|
||||
[ -f "$TASKS_SH" ] || TASKS_SH=$(find_tool assist tools/tasks.sh) \
|
||||
|| die 2 '找不到 tasks.sh,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。'
|
||||
DUE_SH="$HERE/due.sh"
|
||||
[ -f "$DUE_SH" ] || DUE_SH=$(find_tool assist tools/due.sh) || DUE_SH=''
|
||||
|
||||
# --- {cli} 的代入來源 ---
|
||||
|
||||
# 偵測到的 CLI 代號,一行一個。取不到就當成代不出來,帶 {cli} 的那幾筆這一輪跳過。
|
||||
CLIS="$TMPD/clis.txt"
|
||||
: >"$CLIS"
|
||||
if _det=$(find_tool cli tools/detect-clis.sh); then
|
||||
"$_det" 2>/dev/null | awk -F"$TAB" 'NF>0 && $1 != "" {print $1}' >"$CLIS" 2>/dev/null || : >"$CLIS"
|
||||
fi
|
||||
N_CLI=$(awk 'END{print NR+0}' "$CLIS")
|
||||
|
||||
# 跟排程條目安裝當下記下的那一份比對。少了哪一支要點名,不是只印個數字。
|
||||
#
|
||||
# 為什麼要比:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的 PATH 是
|
||||
# 安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——實測有一支裝在帶行程編號的多殼層目錄,
|
||||
# 重開機之後那條路徑就不在了。
|
||||
# 沒有基準的後果不是報錯,是安靜地少做一支:這一輪照樣把偵測到的每一支都跑過、照樣全部
|
||||
# 成功、照樣回報「都跑過了」。實測那一輪報「對 4 支 CLI 都跑過,全部成功」,而機器上裝了
|
||||
# 5 支——報告讀起來完全正常。
|
||||
# 只報不修:那條路徑本質上是暫時的,真正的解法是那支 CLI 該裝在穩定位置,或者重跑一次
|
||||
# 排程安裝重新拍一份快照。兩件都不是這一支該替人決定的。
|
||||
CLI_MISSING=''
|
||||
if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
|
||||
for _want in $JSC_ASSIST_CLIS; do
|
||||
grep -qxF "$_want" "$CLIS" 2>/dev/null || CLI_MISSING="${CLI_MISSING:+$CLI_MISSING、}$_want"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- {repo} 的代入來源 ---
|
||||
|
||||
# 掃到的存取庫工作目錄,一行一條。掃描規則、掃描起點與排除清單全在 scan-repos.sh 那一支,
|
||||
# 這裡不自己再判一次:兩邊各有一套掃描規則就會漂移,而漂移的那一天沒有人會收到通知。
|
||||
# 兩份:REPOS 是路徑清單,給沒綁存取庫的那幾筆代成每一個目標;REPOMAP 多帶一欄
|
||||
# {owner}/{repo},給綁了存取庫的那幾筆對出它綁的是哪一條路徑。
|
||||
REPOS="$TMPD/repos.txt"
|
||||
REPOMAP="$TMPD/repomap.txt"
|
||||
: >"$REPOS"; : >"$REPOMAP"
|
||||
REPO_WHY=''
|
||||
REPO_SCAN='-'
|
||||
SCAN_SH="$HERE/scan-repos.sh"
|
||||
[ -f "$SCAN_SH" ] || SCAN_SH=$(find_tool assist tools/scan-repos.sh) || SCAN_SH=''
|
||||
if [ -z "$SCAN_SH" ]; then
|
||||
REPO_WHY='找不到 scan-repos.sh,{repo} 沒有代入來源'
|
||||
REPO_SCAN='missing'
|
||||
else
|
||||
"$SCAN_SH" list --root "$ROOT" >"$TMPD/scan.out" 2>"$TMPD/scan.err"
|
||||
_src=$?
|
||||
REPO_SCAN="rc$_src"
|
||||
case "$_src" in
|
||||
# 0 是全部都對得出盤點頁頁名,1 是有幾個對不出來或路徑被拒——後者交出來的那幾條照用,
|
||||
# 對不出頁名不影響唯讀盤點跑得動。其餘各碼一律當成沒有來源,並把它印的理由帶上。
|
||||
0|1)
|
||||
sed -n 's/^repo=\([^ ]*\) slug=\([^ ]*\).*/\1'"$TAB"'\2/p' "$TMPD/scan.out" >"$REPOMAP"
|
||||
cut -f1 "$REPOMAP" >"$REPOS" ;;
|
||||
*) REPO_WHY=$(sed -n 's/^\[jsc\]\[助理掃描\]\[ERR\]://p' "$TMPD/scan.err" 2>/dev/null | clip 200)
|
||||
[ -n "$REPO_WHY" ] || REPO_WHY="存取庫掃描回結束碼 $_src,{repo} 代不出目標" ;;
|
||||
esac
|
||||
# 掃描那一支的逐項判定行被導進暫存檔,所以外面看不到。原樣轉出來:被拒的那幾個存取庫是
|
||||
# 這一輪「掃到但沒交出去」的名單,那份名單不能只留在暫存檔裡。
|
||||
sed -n 's/^\(rejected=\|unnamed=\|excluded=\|skipped_link=\|not_a_repo=\)/repo_scan_note=\1/p' \
|
||||
"$TMPD/scan.out" "$TMPD/scan.err" 2>/dev/null
|
||||
fi
|
||||
N_REPOS=$(awk 'END{print NR+0}' "$REPOS")
|
||||
if [ -z "$REPO_WHY" ] && [ "$N_REPOS" -eq 0 ]; then
|
||||
REPO_WHY='存取庫掃描一個都沒掃到,{repo} 代不出目標'
|
||||
fi
|
||||
|
||||
# 這一筆綁的存取庫對出一條掃到的路徑。綁的值可以是工作目錄路徑,也可以是 {owner}/{repo}——
|
||||
# 待辦簿的欄位說明只寫「這一筆綁哪一個存取庫」,兩種寫法都有人會填,所以兩種都認。
|
||||
# 對不出來一律回失敗,不退回「全部存取庫」:那一筆指名了一個目標,代成全部就是跑了
|
||||
# 十九個沒有人要它跑的地方。
|
||||
repo_path_of() { # $1=綁定值
|
||||
awk -F"$TAB" -v want="$1" '$1==want || $2==want {print $1; found=1; exit} END{exit !found}' "$REPOMAP"
|
||||
}
|
||||
|
||||
# --- 判斷動作是哪一種 ---
|
||||
|
||||
# 技能名的形狀是 jsc-{domain}:{技能名}。比對整串,不用前綴比對:一行以 jsc- 開頭的指令
|
||||
# 路徑不該被當成技能名。
|
||||
is_skill_name() { # $1=action
|
||||
case "$1" in
|
||||
jsc-*:*)
|
||||
case "$1" in
|
||||
*' '*|*/*) return 1 ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# 執行前再驗一次指令形狀。種入那一支驗過同樣幾項,這裡不省:待辦檔是純文字,中間可能被改。
|
||||
# 驗不過就當成代不出來,跳過不跑,不記失敗——這一筆的內容有問題,那是清單或待辦檔的事。
|
||||
cmd_shape_ok() { # $1=指令
|
||||
case "$1" in
|
||||
*'$'*|*'`'*|*'~'*) BAD_WHY='指令裡有金錢符號、反引號或波浪號'; return 1 ;;
|
||||
*';'*|*'|'*|*'&'*) BAD_WHY='指令裡有分號、管線或連接符號,一筆內建項只該是一行單一指令'; return 1 ;;
|
||||
esac
|
||||
# 路徑一定要落在這一輪的根目錄底下。字面絕對路徑是權限層唯一認得的形狀,也是唯一
|
||||
# 看得出「這支腳本是不是我們自己的」的形狀。
|
||||
case "$1" in
|
||||
*"$ROOT"/*) ;;
|
||||
*) BAD_WHY="指令裡沒有這一輪的根目錄 $ROOT"; return 1 ;;
|
||||
esac
|
||||
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
|
||||
RC_CMD=0; RC_WRITE=0
|
||||
|
||||
# 欄位順序取自 due.sh 印的 rows_columns=。這裡寫死同一個順序,換了就對不上——所以先驗一次
|
||||
# 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。
|
||||
_cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null)
|
||||
case "${_cols:-0}" in
|
||||
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
|
||||
[ -n "${c_id:-}" ] || continue
|
||||
[ "${c_verdict:-}" = "due" ] || continue
|
||||
N_DUE=$((N_DUE + 1))
|
||||
|
||||
# 只跑內建項。使用者交辦的那幾筆沒有經過種入那一支的檢核,動作欄想寫什麼都行。
|
||||
_spec=$(sed -n 's/^spec_key=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
|
||||
if [ -z "$_spec" ]; then
|
||||
N_SKIP=$((N_SKIP + 1))
|
||||
printf 'skip=%s reason=不是委派清單種入的內建項,這一支不跑使用者交辦的那幾筆\n' "$c_id"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 三種動作,這一輪只跑第一種。
|
||||
if [ "$c_action" = "remind" ]; then
|
||||
N_SKIP=$((N_SKIP + 1))
|
||||
printf 'skip=%s spec=%s reason=動作是只提醒,沒有東西可跑\n' "$c_id" "$_spec"
|
||||
continue
|
||||
fi
|
||||
if is_skill_name "$c_action"; then
|
||||
N_SKIP=$((N_SKIP + 1))
|
||||
printf 'skip=%s spec=%s reason=動作是技能名,叫用整支技能這一批還沒接 action=%s\n' \
|
||||
"$c_id" "$_spec" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
BAD_WHY=''
|
||||
if ! cmd_shape_ok "$c_action"; then
|
||||
N_HELD=$((N_HELD + 1))
|
||||
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$BAD_WHY" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 代入點展開:一個目標一行,寫進暫存檔。
|
||||
_targets="$TMPD/targets.$c_id"
|
||||
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
|
||||
_hold=''
|
||||
# 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。
|
||||
_repovals="$REPOS"
|
||||
case "$c_action" in
|
||||
*'{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
|
||||
# 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {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))
|
||||
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$_hold" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 代完之後還留著大括號就是還有認不得的代入點。原樣送進殼會跑到一個沒有人寫過的地方。
|
||||
if grep -q '[{}]' "$_targets" 2>/dev/null; then
|
||||
N_HELD=$((N_HELD + 1))
|
||||
printf 'held=%s spec=%s reason=代完之後還留著大括號,認不得的代入點 action=%s\n' \
|
||||
"$c_id" "$_spec" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
_n=$(awk 'END{print NR+0}' "$_targets")
|
||||
if [ "$DRYRUN" -eq 1 ]; then
|
||||
N_RUN=$((N_RUN + 1))
|
||||
printf 'would_run=%s spec=%s targets=%s\n' "$c_id" "$_spec" "$_n"
|
||||
while IFS= read -r _t; do printf ' target=%s\n' "$_t"; done <"$_targets"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 真的跑。一個目標一次,全部成功這一筆才算成功——一支 CLI 接線壞掉就是一筆要人看的發現。
|
||||
N_RUN=$((N_RUN + 1))
|
||||
_entry_rc=0
|
||||
_first_err=''
|
||||
while IFS= read -r _t; do
|
||||
[ -n "$_t" ] || continue
|
||||
_out=$(sh -c "$_t" </dev/null 2>&1); _rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t"
|
||||
else
|
||||
_entry_rc=1
|
||||
[ -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" | clip 160)"
|
||||
fi
|
||||
done <"$_targets"
|
||||
|
||||
# 回寫。下一次什麼時候到期由判定那一支算,這一支不自己算——兩邊各算一次就會漂移。
|
||||
if [ "$_entry_rc" -eq 0 ]; then
|
||||
_next=''
|
||||
if [ -n "$DUE_SH" ]; then
|
||||
_next=$("$DUE_SH" next --trigger "$c_trigger" --recur "$c_recur" \
|
||||
--last-run "$NOW_ISO" --now "$NOW" 2>/dev/null | sed -n 's/^next_run=//p' | head -n1)
|
||||
fi
|
||||
if [ -n "$_next" ]; then
|
||||
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" --next-run "$_next" >/dev/null 2>&1
|
||||
else
|
||||
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1
|
||||
fi
|
||||
_wrc=$?
|
||||
if [ "$_wrc" -eq 0 ]; then
|
||||
N_OK=$((N_OK + 1))
|
||||
printf 'done=%s spec=%s targets=%s next_run=%s\n' "$c_id" "$_spec" "$_n" "${_next:--}"
|
||||
else
|
||||
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
|
||||
printf 'write_failed=%s spec=%s op=done rc=%s\n' "$c_id" "$_spec" "$_wrc"
|
||||
fi
|
||||
else
|
||||
RC_CMD=1
|
||||
"$TASKS_SH" fail "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1; _wrc=$?
|
||||
if [ "$_wrc" -eq 0 ]; then
|
||||
N_FAIL=$((N_FAIL + 1))
|
||||
printf 'failed=%s spec=%s targets=%s first_error=%s\n' "$c_id" "$_spec" "$_n" "${_first_err:--}"
|
||||
else
|
||||
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
|
||||
printf 'write_failed=%s spec=%s op=fail rc=%s\n' "$c_id" "$_spec" "$_wrc"
|
||||
fi
|
||||
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 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_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= 那幾行。"
|
||||
[ "$RC_WRITE" -ne 0 ] && warn "有 $N_WRITE_BAD 筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆,逐筆印在上面的 write_failed= 那幾行,各自帶了 tasks.sh 的結束碼。"
|
||||
[ "$RC_CMD" -ne 0 ] && warn "有 $N_FAIL 筆的指令回非零,已經記成失敗、失敗次數加一。逐筆印在上面的 failed= 與 target_fail= 那幾行。"
|
||||
|
||||
[ "$RC_WRITE" -eq 0 ] || exit 4
|
||||
[ "$RC_CMD" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env sh
|
||||
# scan-repos.sh — 掃出工作目錄底下的存取庫,交出 {repo} 代入點要用的那份清單。
|
||||
#
|
||||
# 用法:
|
||||
# scan-repos.sh list [--scan-root {字面絕對路徑}] [--depth {0..3}] [--root {字面絕對根目錄}]
|
||||
# scan-repos.sh paths [同上]
|
||||
#
|
||||
# list 一行一個存取庫,帶工作目錄、{owner}/{repo} 與對應的 REPO_{HASH} 頁名
|
||||
# paths 只印工作目錄,一行一條。給 {repo} 代入點直接讀
|
||||
#
|
||||
# 環境變數:
|
||||
# JSC_ASSIST_SCAN_ROOT 掃描起點。--scan-root 優先於它
|
||||
# JSC_ASSIST_SCAN_EXCLUDE 排除清單,空白分隔,比對目錄名(不是整條路徑),可用萬用字元
|
||||
# JSC_ASSIST_HASH_ID hash-id 路徑覆寫;找不到並排存取庫時才設
|
||||
#
|
||||
# 結束碼:
|
||||
# 0 掃到至少一個存取庫,而且每一個都對得出 REPO_{HASH}、路徑也都送得進殼
|
||||
# 1 掃到了,但有幾個對不出頁名或路徑被拒。可用的那幾個照印,被拒的逐行印在 rejected=
|
||||
# 有 .git 卻不被 git 認的那幾個也算在這一碼裡,印在 not_a_repo=
|
||||
# 2 用法錯誤:不認得的子命令或選項、選項缺值、--scan-root 不是絕對路徑、--depth 超出範圍
|
||||
# 3 掃描起點沒設定,或那條路徑不是存在的目錄——**什麼都沒掃**,不等於「這台機器沒有存取庫」
|
||||
# 4 掃過了,一個 git 存取庫都沒有
|
||||
#
|
||||
# --- 為什麼掃描起點不給預設值 ---
|
||||
#
|
||||
# 這份清單的用途是把存取庫路徑代進待辦簿的指令,然後在無人值守那一輪送進殼執行。
|
||||
# 猜錯掃描起點的後果不是掃不到東西,是**在猜錯的那些目錄底下跑指令**,而那一輪沒有人
|
||||
# 看得到它跑到哪裡去了。往上猜一層就從工作目錄變成家目錄,再往上就是整台機器。
|
||||
# 所以沒設就回結束碼 3 並說清楚要設哪一個變數,不退回任何一條路徑。
|
||||
# 排程那一輪的值由 schedule.sh install 當下從殼裡快照進條目,跟 wiki 存取庫那幾個變數同一條路。
|
||||
#
|
||||
# --- 為什麼預設只掃一層 ---
|
||||
#
|
||||
# 工作目錄的版面是「一個存取庫一個子目錄」,一層就夠。往下遞歸會踩到三種東西:
|
||||
# 存取庫自己的 node_modules 與 vendor 底下的第三方存取庫、封存目錄裡的舊版存取庫、
|
||||
# 還有 worktree 的複本。三種都會被當成現役存取庫盤點,而盤點結果讀起來完全正常。
|
||||
# --depth 最多收到 3:真的有嵌套版面時夠用,而再深就不是「工作目錄那一層」了,
|
||||
# 那種版面該把掃描起點指到那一層,不是把界線調鬆。
|
||||
#
|
||||
# --- 三種東西一定跳過,不看排除清單 ---
|
||||
#
|
||||
# 一、名稱以點開頭的目錄。快取、封存、暫存都藏在那裡,掃進去會把封存版當現役版盤點。
|
||||
# 二、符號連結。連結會把掃描帶出掃描起點,而那條界線一旦被繞過就沒有第二道。
|
||||
# 三、路徑裡有空白或殼層特殊字元的存取庫。執行那一支是用 `sh -c` 送出指令的,那條路徑
|
||||
# 會被殼再解一次——帶空白的路徑會被切成兩個參數,帶金錢符號的會被展開成別的東西。
|
||||
# 這種一律列進 rejected= 不交出去,不是靜靜跳過:那是一個真的存在的存取庫,
|
||||
# 它沒有被盤點到這件事要有人知道。
|
||||
set -u
|
||||
|
||||
usage() {
|
||||
echo 'usage: scan-repos.sh {list|paths} [--scan-root 絕對路徑] [--depth 0..3] [--root 絕對路徑]' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
die() { _c=$1; shift; printf '[jsc][助理掃描][ERR]:%s\n' "$*" >&2; exit "$_c"; }
|
||||
# 逐項的判定行與收尾統計。paths 那個模式的標準輸出要是純路徑——呼叫端拿它當代入點來源,
|
||||
# 混一行 unnamed= 進去就會被代成一條不存在的路徑然後送進殼。所以那個模式一律改走標準錯誤。
|
||||
emit() { if [ "$MODE" = 'paths' ]; then printf '%s\n' "$*" >&2; else printf '%s\n' "$*"; fi; }
|
||||
note() { printf '[jsc][助理掃描]:%s\n' "$*" >&2; }
|
||||
warn() { printf '[jsc][助理掃描][WARN]:%s\n' "$*" >&2; }
|
||||
|
||||
[ "$#" -ge 1 ] || usage
|
||||
MODE=$1; shift
|
||||
case "$MODE" in
|
||||
list|paths) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
SCAN_ROOT=''
|
||||
OPT_ROOT=''
|
||||
DEPTH=1
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--scan-root) [ "$#" -ge 2 ] || usage; SCAN_ROOT=$2; shift 2 ;;
|
||||
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
|
||||
--depth) [ "$#" -ge 2 ] || usage; DEPTH=$2; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$DEPTH" in
|
||||
0|1|2|3) ;;
|
||||
*) die 2 "--depth 只收 0 到 3,收到的是「$DEPTH」。0 只看掃描起點自己,1 是它底下那一層。要掃更深的版面就把掃描起點指到那一層。" ;;
|
||||
esac
|
||||
|
||||
case "$OPT_ROOT" in
|
||||
''|/*) ;;
|
||||
*) die 2 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
|
||||
esac
|
||||
|
||||
[ -n "$SCAN_ROOT" ] || SCAN_ROOT="${JSC_ASSIST_SCAN_ROOT:-}"
|
||||
if [ -z "$SCAN_ROOT" ]; then
|
||||
die 3 '掃描起點沒設定。這一支不猜:猜錯的後果是在猜錯的那些目錄底下跑指令,而無人值守那一輪沒有人看得到它跑到哪裡去了。請帶 --scan-root,或在殼裡設 JSC_ASSIST_SCAN_ROOT 之後重跑一次 schedule.sh install patrol,把值快照進排程條目。'
|
||||
fi
|
||||
case "$SCAN_ROOT" in
|
||||
/*) ;;
|
||||
*) die 2 "掃描起點要是字面絕對路徑,收到的是「$SCAN_ROOT」。權限層比對的是還沒展開的指令字面,帶變數或波浪號的路徑進不了允許清單。" ;;
|
||||
esac
|
||||
SCAN_ROOT=${SCAN_ROOT%/}
|
||||
# 去掉尾斜線之後空字串就是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,
|
||||
# 那不是設定錯誤就是打錯字,兩種都不該照著跑。
|
||||
[ -n "$SCAN_ROOT" ] || die 2 '掃描起點是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,這一支不接。'
|
||||
[ -d "$SCAN_ROOT" ] || die 3 "掃描起點不是存在的目錄:$SCAN_ROOT。什麼都沒掃,跟「這台機器沒有存取庫」不是同一件事——後者要回 4。"
|
||||
|
||||
TAB=$(printf '\t')
|
||||
TMPD=$(mktemp -d 2>/dev/null) || die 3 '暫存目錄建不起來。'
|
||||
trap 'rm -rf "$TMPD"' EXIT
|
||||
|
||||
# --- 找 hash-id ---
|
||||
|
||||
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT="$OPT_ROOT"
|
||||
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
|
||||
|
||||
hash_id_sh() {
|
||||
if [ -n "${JSC_ASSIST_HASH_ID:-}" ] && [ -f "$JSC_ASSIST_HASH_ID" ]; then
|
||||
printf '%s' "$JSC_ASSIST_HASH_ID"; return 0
|
||||
fi
|
||||
for _d in "jsc-gitea" "gitea"; do
|
||||
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/tools/hash-id" ] && { printf '%s' "$ROOT/$_d/tools/hash-id"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
HASH_ID=$(hash_id_sh) || HASH_ID=''
|
||||
|
||||
# 頁名的雜湊規則跟 jsc-sdlc 盤點頁是同一條:對那個存取庫自己的 {owner}/{repo} 取完整四十碼
|
||||
# 大寫 SHA-1。共用那一支找不到就退回本機的 sha1sum——同一條規則、同一個值,只是少一層共用。
|
||||
# 兩邊都算不出來就不硬湊一個頁名:湊出來的頁名指向沒有人讀的那一頁。
|
||||
#
|
||||
# 用哪一種先決定好,不在每一筆裡各判一次。算雜湊那一段跑在命令替換裡,也就是子殼裡,
|
||||
# 在那裡設「這一輪用了退路」的旗標,回到外面就沒了——那句提示會永遠印不出來。
|
||||
HASH_MODE='none'
|
||||
if [ -n "$HASH_ID" ] && [ -n "$(printf '%s' probe | "$HASH_ID" 2>/dev/null)" ]; then
|
||||
HASH_MODE='shared'
|
||||
elif command -v sha1sum >/dev/null 2>&1; then
|
||||
HASH_MODE='sha1sum'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
HASH_MODE='shasum'
|
||||
fi
|
||||
hash40() { # $1=要算的字串
|
||||
case "$HASH_MODE" in
|
||||
shared) printf '%s' "$1" | "$HASH_ID" 2>/dev/null ;;
|
||||
sha1sum) printf '%s' "$1" | sha1sum | awk '{print toupper($1)}' ;;
|
||||
shasum) printf '%s' "$1" | shasum -a 1 | awk '{print toupper($1)}' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- 找候選目錄 ---
|
||||
|
||||
# 排除清單比對目錄名,不比對整條路徑:整條路徑會隨掃描起點變,同一份清單換一台機器就失效。
|
||||
EXCLUDED_NAMES="${JSC_ASSIST_SCAN_EXCLUDE:-}"
|
||||
excluded() { # $1=目錄名
|
||||
[ -n "$EXCLUDED_NAMES" ] || return 1
|
||||
for _pat in $EXCLUDED_NAMES; do
|
||||
# shellcheck disable=SC2254
|
||||
case "$1" in
|
||||
$_pat) return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
CAND="$TMPD/cand.txt"
|
||||
: >"$CAND"
|
||||
LINKS="$TMPD/links.txt"
|
||||
: >"$LINKS"
|
||||
|
||||
# find 預設不跟符號連結走,所以連結目錄不會出現在 -type d 的結果裡。這裡另外撈一次 -type l
|
||||
# 是為了把它們印出來:一個被跳過的連結跟一個不存在的目錄,在報告上看起來不能是同一件事。
|
||||
#
|
||||
# 排除清單不寫進 find 的剪枝條件,改在下面逐個判定時濾掉。理由是剪掉的目錄就不會出現在
|
||||
# 結果裡,也就報不出「這個被排除了」——而一份沒說自己排除了什麼的清單,讀起來跟沒有排除
|
||||
# 清單一樣。代價是深度兩層以上時會走進被排除的目錄裡再一層,而深度上限是 3,所以最多一層。
|
||||
if [ "$DEPTH" -ge 1 ]; then
|
||||
find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \
|
||||
-name '.*' -prune -o -type l -print 2>/dev/null >"$LINKS"
|
||||
find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \
|
||||
-name '.*' -prune -o -type d -print 2>/dev/null >"$CAND"
|
||||
fi
|
||||
# depth 0 與更深的版面都要把掃描起點自己算進來:工作目錄本身也可能就是一個存取庫。
|
||||
printf '%s\n' "$SCAN_ROOT" >>"$CAND"
|
||||
sort -u -o "$CAND" "$CAND"
|
||||
|
||||
# --- 逐個判定 ---
|
||||
|
||||
N_REPO=0; N_REJ=0; N_UNNAMED=0; N_EXCL=0; N_LINK=0; N_FAKE=0
|
||||
OUT="$TMPD/out.txt"
|
||||
: >"$OUT"
|
||||
|
||||
while IFS= read -r d; do
|
||||
[ -n "$d" ] || continue
|
||||
_name=$(basename -- "$d")
|
||||
if excluded "$_name"; then
|
||||
N_EXCL=$((N_EXCL + 1))
|
||||
emit "excluded=$d reason=在排除清單上"
|
||||
continue
|
||||
fi
|
||||
# .git 可能是目錄(一般存取庫),也可能是檔案(worktree 與子模組),所以先看它在不在,
|
||||
# 但**在不在只是初篩,不是判定**。
|
||||
[ -e "$d/.git" ] || continue
|
||||
|
||||
# 判定交給 git 自己。有 .git 不等於是存取庫:實測這台機器上有一個目錄底下的 .git 只剩
|
||||
# 一個空的 info/,git 一句「not a git repository」,而只看 .git 在不在的掃描把它算成
|
||||
# 一個存取庫、印成「沒有 origin」——那個說法讀起來像「這個存取庫還沒接遠端」,
|
||||
# 不像「這裡根本不是存取庫」。兩件事的處置完全不同。
|
||||
#
|
||||
# 只問「是不是在工作樹裡」不夠:git 會從這個目錄往上找,$d/.git 壞掉時它會找到上一層的
|
||||
# 存取庫然後回答「是」。所以要再核對它認定的頂層就是 $d 自己。兩邊都解成實體路徑再比,
|
||||
# 掃描起點帶符號連結那一段時字串才對得上。
|
||||
_top=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null) || _top=''
|
||||
_dreal=$(CDPATH= cd -P -- "$d" 2>/dev/null && pwd -P) || _dreal=''
|
||||
_topreal=''
|
||||
[ -n "$_top" ] && { _topreal=$(CDPATH= cd -P -- "$_top" 2>/dev/null && pwd -P) || _topreal=''; }
|
||||
if [ -z "$_topreal" ] || [ "$_topreal" != "$_dreal" ]; then
|
||||
N_FAKE=$((N_FAKE + 1))
|
||||
if [ -z "$_top" ]; then
|
||||
emit "not_a_repo=$d reason=有 .git 但 git 自己不認,多半是刪剩的殘骸"
|
||||
else
|
||||
emit "not_a_repo=$d reason=有 .git 但 git 認定的頂層是 $_top,這裡不是存取庫的根"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$d" in
|
||||
*[!A-Za-z0-9/._-]*)
|
||||
N_REJ=$((N_REJ + 1))
|
||||
emit "rejected=$d reason=路徑裡有空白或殼層特殊字元,送進 sh -c 會被再解一次"
|
||||
continue ;;
|
||||
esac
|
||||
|
||||
_url=$(git -C "$d" remote get-url origin 2>/dev/null) || _url=''
|
||||
_slug=''
|
||||
if [ -n "$_url" ]; then
|
||||
# 兩種形狀:git@host:owner/repo.git 與 https://host/owner/repo.git。都只取最後兩段。
|
||||
_s=${_url%.git}
|
||||
_s=${_s#*://}
|
||||
_s=${_s#*@}
|
||||
case "$_s" in
|
||||
*:*) _s=${_s#*:} ;;
|
||||
esac
|
||||
_s=${_s#/}
|
||||
_owner=''
|
||||
_repo=${_s##*/}
|
||||
_rest=${_s%/*}
|
||||
[ "$_rest" != "$_s" ] && _owner=${_rest##*/}
|
||||
[ -n "$_owner" ] && [ -n "$_repo" ] && _slug="$_owner/$_repo"
|
||||
fi
|
||||
|
||||
_page='-'
|
||||
if [ -n "$_slug" ]; then
|
||||
_h=$(hash40 "$_slug") || _h=''
|
||||
[ -n "$_h" ] && _page="REPO_$_h"
|
||||
fi
|
||||
if [ "$_page" = '-' ]; then
|
||||
N_UNNAMED=$((N_UNNAMED + 1))
|
||||
if [ -z "$_slug" ]; then
|
||||
emit "unnamed=$d reason=沒有 origin 遠端,或網址對不出 {owner}/{repo},算不出盤點頁頁名"
|
||||
else
|
||||
emit "unnamed=$d slug=$_slug reason=這台機器算不出 SHA-1,算不出盤點頁頁名"
|
||||
fi
|
||||
fi
|
||||
|
||||
N_REPO=$((N_REPO + 1))
|
||||
printf '%s%s%s%s%s\n' "$d" "$TAB" "${_slug:--}" "$TAB" "$_page" >>"$OUT"
|
||||
done <"$CAND"
|
||||
|
||||
while IFS= read -r l; do
|
||||
[ -n "$l" ] || continue
|
||||
N_LINK=$((N_LINK + 1))
|
||||
emit "skipped_link=$l reason=符號連結,跟著走會把掃描帶出掃描起點"
|
||||
done <"$LINKS"
|
||||
|
||||
if [ "$MODE" = 'paths' ]; then
|
||||
cut -f1 "$OUT"
|
||||
else
|
||||
while IFS="$TAB" read -r p s g; do
|
||||
printf 'repo=%s slug=%s page=%s\n' "$p" "$s" "$g"
|
||||
done <"$OUT"
|
||||
fi
|
||||
|
||||
emit "scan_root=$SCAN_ROOT depth=$DEPTH repos=$N_REPO unnamed=$N_UNNAMED rejected=$N_REJ excluded=$N_EXCL skipped_links=$N_LINK not_a_repo=$N_FAKE"
|
||||
|
||||
case "$HASH_MODE" in
|
||||
sha1sum|shasum) note '找不到共用那一支 hash-id(jsc-gitea 的 tools/hash-id),這一輪的頁名改用本機的 SHA-1 算。兩者是同一條規則、值相同,只是少了一層共用;--root 餵對就會用共用那一支。' ;;
|
||||
none) warn '這台機器既沒有共用那一支 hash-id,也沒有 sha1sum 與 shasum,這一輪一個盤點頁頁名都算不出來。存取庫路徑照樣交出去代 {repo},唯讀盤點跑得動;盤點結果沒有頁可以寫回去。' ;;
|
||||
esac
|
||||
[ "$N_REJ" -gt 0 ] && warn "有 $N_REJ 個存取庫的路徑送不進殼,這一份清單裡沒有它們,逐個印在上面的 rejected= 那幾行。那幾個是真的存在的存取庫,只是路徑帶了空白或殼層特殊字元——要盤點得到就得換路徑。"
|
||||
[ "$N_UNNAMED" -gt 0 ] && warn "有 $N_UNNAMED 個存取庫對不出 REPO_{HASH} 盤點頁頁名,逐個印在上面的 unnamed= 那幾行。它們的路徑照樣交出去代 {repo},唯讀盤點跑得動;但盤點結果沒有頁可以寫回去。"
|
||||
|
||||
if [ "$N_REPO" -eq 0 ]; then
|
||||
die 4 "掃過 $SCAN_ROOT 底下 $DEPTH 層,一個 git 存取庫都沒有。掃描起點指錯一層就會長這樣——工作目錄的版面是「一個存取庫一個子目錄」,指到某一個存取庫裡面就只剩它自己那一個,指到家目錄就一個都沒有。"
|
||||
fi
|
||||
[ "$N_FAKE" -gt 0 ] && warn "有 $N_FAKE 個目錄底下有 .git 卻不被 git 認,逐個印在上面的 not_a_repo= 那幾行。那不是「還沒接遠端的存取庫」,是刪剩的殘骸或者不是存取庫的根——只看 .git 在不在的掃描會把它算成一個存取庫。"
|
||||
|
||||
if [ "$N_REJ" -gt 0 ] || [ "$N_UNNAMED" -gt 0 ] || [ "$N_FAKE" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
+142
-5
@@ -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
|
||||
@@ -303,7 +307,15 @@ patrol_command() {
|
||||
[ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; }
|
||||
_cli="$CLI"
|
||||
[ -n "$_cli" ] || _cli="${JSC_CLI:-}"
|
||||
[ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; }
|
||||
# 認得出 claude 的三個標記要跟 jsc-hooks 的 cli_name() 一模一樣。這裡本來只認第一個,
|
||||
# 而工具腳本被叫用時那一個不會設,於是每一次不帶 --cli 的安裝都回用法錯誤——技能本文
|
||||
# 的啟動步驟叫的正是不帶 --cli 那一種。同一個判斷寫兩份就是這樣漂走的:共用函式後來
|
||||
# 補了兩個標記,這一份沒跟上,而漂移的代價是一個看起來像「這台機器沒裝 CLI」的錯誤。
|
||||
# 共用函式加新標記時,這裡要一起加。
|
||||
if [ -z "$_cli" ] && { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDECODE:-}" ] \
|
||||
|| [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; }; then
|
||||
_cli=claude
|
||||
fi
|
||||
case "$_cli" in
|
||||
claude) _bin='claude'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;;
|
||||
codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢 $ROOT_TOKEN'" ;;
|
||||
@@ -328,7 +340,20 @@ patrol_command() {
|
||||
# 不寫死是刻意的:頁型會增加,目錄頁後來也分了自己的存取庫變數,寫死清單就要跟著改一次,
|
||||
# 而漏掉的那一個在排程那一輪是空值,wiki 寫不成、心跳不寫,外面只看得到心跳過期。
|
||||
snapshot_names() {
|
||||
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
|
||||
# PATH 也要快照。cron 給的 PATH 只有 /usr/bin 與 /bin,而這台機器的 CLI 執行檔多半裝在
|
||||
# 家目錄底下,所以那一輪偵測 CLI 會一支都找不到——而且是**回結束碼 0、印出空清單**,
|
||||
# 不是失敗。看起來像「這台機器沒裝 CLI」,實際上是 PATH 上找不到。
|
||||
# 2026-09-04 實測踩到:無人值守那一輪 clis=0,帶 {cli} 代入點的內建項全部代不出目標,
|
||||
# 而同一支腳本在有人的 shell 裡回五支。
|
||||
# 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己
|
||||
# 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份
|
||||
# 路徑清單,而清單會過期。
|
||||
# 掃描起點與排除清單也要快照。存取庫掃描那一支刻意不猜掃描起點,沒有值就整個代入點
|
||||
# 代不出來,帶 {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
|
||||
@@ -338,12 +363,68 @@ snapshot_names() {
|
||||
# 整條 cron 指令切斷。
|
||||
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
|
||||
# 條目要帶的 PATH。不是整條照抄——這台機器實測 2160 字元,去重又去掉不存在的那幾段之後
|
||||
# 還有 1332 字元,而 crontab 一行有長度上限,照抄會直接回「command too long」而裝不起來。
|
||||
# 實測踩過:加上整條 PATH 之後 install 回 4,錯誤訊息是 crontab 自己吐的。
|
||||
#
|
||||
# 只帶放得下 CLI 執行檔的那幾個目錄,這台機器實測是兩個、合起來 70 字元。這樣夠用的理由是
|
||||
# 這一條要解決的問題很窄:cron 只給 /usr/bin 與 /bin,而偵測 CLI 那一支靠 PATH 找執行檔,
|
||||
# 找不到就回結束碼 0 加一份空清單——看起來像「這台機器沒裝 CLI」。巡檢要跑的指令本身走的是
|
||||
# 字面絕對路徑,不靠這一條。
|
||||
#
|
||||
# 一個代價要講白:有些 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),那種路徑
|
||||
# 下一次開機就不在了。這裡不特別過濾——過濾要認得每一種安裝方式,那份名單會過期得比路徑
|
||||
# 還快。重跑一次 install 就會重新拍一份,而條目本來就是安裝當下的快照。
|
||||
cli_path() {
|
||||
_dirs=''
|
||||
for _c in claude codex copilot agy kiro-cli; do
|
||||
_abs=$(command -v "$_c" 2>/dev/null) || continue
|
||||
case "$_abs" in /*) ;; *) continue ;; esac
|
||||
_d=$(dirname -- "$_abs")
|
||||
case ":$_dirs:" in
|
||||
*":$_d:"*) ;;
|
||||
*) _dirs="${_dirs:+$_dirs:}$_d" ;;
|
||||
esac
|
||||
done
|
||||
# 收尾接上 cron 本來就會給的那兩段,免得只剩自訂目錄而找不到系統工具。
|
||||
printf '%s' "${_dirs:+$_dirs:}/usr/bin:/bin"
|
||||
}
|
||||
|
||||
# 條目要記下安裝當下偵測到哪幾支 CLI,作為往後每一輪的參照基準。
|
||||
#
|
||||
# 為什麼需要這個基準:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的
|
||||
# PATH 是安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——這台機器實測就有一支裝在帶行程
|
||||
# 編號的多殼層目錄,重開機之後那條路徑就不在了。
|
||||
# 少了基準的後果不是報錯,是安靜地少做一支:那一輪照樣把偵測到的每一支都跑過、照樣全部
|
||||
# 成功、照樣回報「都跑過了」,因為它不知道該有幾支。實測那一輪報「對 4 支 CLI 都跑過,
|
||||
# 全部成功」,而機器上裝了 5 支。
|
||||
# 記下來之後,讀的那一邊才有東西可比,也才答得出「該不該重跑一次 install」。
|
||||
# 判法照 cli_path() 那一套:逐支用 command -v 試,不叫外部腳本。兩個理由——這一支在 install
|
||||
# 那一刻本來就已經用同一套判過一次(為了組 PATH),再叫一支等於同一件事兩套實作;而且那一支
|
||||
# 只看 PATH,跟這裡拿得到的資訊一樣多,多繞一層沒有換到任何東西。
|
||||
cli_codes() {
|
||||
_codes=''
|
||||
for _pair in claude:claude codex:codex copilot:copilot antigravity:agy kiro:kiro-cli; do
|
||||
_code=${_pair%%:*}; _bin=${_pair#*:}
|
||||
_abs=$(command -v "$_bin" 2>/dev/null) || continue
|
||||
case "$_abs" in /*) ;; *) continue ;; esac
|
||||
_codes="${_codes:+$_codes }$_code"
|
||||
done
|
||||
printf '%s' "$_codes"
|
||||
}
|
||||
|
||||
# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值——
|
||||
# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
|
||||
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
|
||||
# 一定寫不成,而頁寫不成就不寫心跳,排程等於永遠空轉。
|
||||
# 刻意不寫死工作階段代號。寫死的話那一輪的兩半會落在不同的值上:start 由 hook 寫,它從 hook
|
||||
# 的標準輸入 JSON 讀得到 CLI 真正的代號;end 由工具腳本寫,沒有那份 JSON,只能讀環境變數,
|
||||
# 於是讀到寫死的那個字串。兩半的代號不同就永遠配不起來,每一輪都被算成一支中止的技能,
|
||||
# 而那個數字只增不減,最後把真的中止蓋掉——那一節存在的理由正是「只有 start 沒有 end 是
|
||||
# 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。
|
||||
# 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。
|
||||
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
|
||||
_p='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes'
|
||||
_p="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
|
||||
_names=''
|
||||
for _n in $(snapshot_names); do
|
||||
eval "_v=\${$_n:-}"
|
||||
@@ -364,7 +445,7 @@ mask_secret() { sed "s/GITEA_TOKEN=[^ ]*/GITEA_TOKEN='***'/g"; }
|
||||
cron_entry() { # $1=工作代號
|
||||
_spec=$(spec_of "$1")
|
||||
case "$1" in
|
||||
heartbeat) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule '$HEARTBEAT' write" ;;
|
||||
heartbeat) _cmd="JSC_CLI=cron '$HEARTBEAT' write" ;;
|
||||
patrol) _cmd="$ENV_PREFIX $PATROL_RESOLVED" ;;
|
||||
esac
|
||||
printf '%s %s </dev/null >>%s 2>&1 %s' \
|
||||
@@ -445,7 +526,7 @@ trap 'rm -rf "$TMPD"' EXIT
|
||||
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
|
||||
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
|
||||
PATROL_RESOLVED=''
|
||||
ENV_PREFIX='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes'
|
||||
ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
|
||||
SNAPSHOT_NAMES=''
|
||||
case "$CMD:$JOBS" in
|
||||
install:*patrol*)
|
||||
@@ -484,8 +565,12 @@ print_allow_rules() {
|
||||
# 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的,
|
||||
# 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按
|
||||
# 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。
|
||||
# run-due.sh 是巡檢那一輪真的執行到期內建項的那一支,技能自己用 Bash 叫它。少了這一條,
|
||||
# 那一輪會停在那一步的權限詢問,而鎖已經握在手上:下一輪跟著讓開,一直到鎖逾時為止。
|
||||
# 它自己跑的那幾支盤點指令是它的子行程,不會再問一次,所以只要放行它自己這一條。
|
||||
for _s in "$ROOT/jsc-assist/tools/schedule.sh" \
|
||||
"$ROOT/jsc-assist/tools/patrol.sh" \
|
||||
"$ROOT/jsc-assist/tools/run-due.sh" \
|
||||
"$ROOT/jsc-hooks/hooks/heartbeat.sh" \
|
||||
"$ROOT/jsc-hooks/tools/report-status.sh" \
|
||||
"$ROOT/jsc-gitea/tools/gitea.sh" \
|
||||
@@ -494,12 +579,42 @@ print_allow_rules() {
|
||||
printf 'allow_rule=Bash(%s:*)\n' "$_s"
|
||||
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
|
||||
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
|
||||
_printed="${_printed:-} ${_s#"$ROOT/"}"
|
||||
done
|
||||
# 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名
|
||||
# 等於這條規則永遠比對不到。
|
||||
printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR"
|
||||
printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR"
|
||||
printf 'allow_rule=Skill(jsc-gitea:wiki)\n'
|
||||
|
||||
# 上面那份清單是寫死的,而技能本文哪天多叫一支腳本,這裡不會自己知道。漏一支的代價不是
|
||||
# 報錯,是那一輪停在權限詢問、鎖握在手上,接下來每一輪都讓開到鎖逾時為止——查起來只看得到
|
||||
# 「心跳過期」。所以這裡回頭掃一次技能本文,把它叫的每一支腳本跟上面印過的比對。
|
||||
# 只警告不中止:本文的寫法可能變,比對規則跟不上時不該連排程都裝不起來。
|
||||
# 目錄名以 jsc-assist 為準,找不到才退回不帶前綴的 assist:安裝版面是前者,開發用的並排
|
||||
# 工作目錄版面是後者。只認一種的話,在另一種版面底下這道檢核會被 [ -f ] 靜靜跳過——
|
||||
# 一道「找不到就當通過」的檢核,跟沒有那道檢核是同一件事。
|
||||
_skill=''
|
||||
for _cand in "$ROOT/jsc-assist/skills/assistant/SKILL.md" \
|
||||
"$ROOT/assist/skills/assistant/SKILL.md"; do
|
||||
[ -f "$_cand" ] && { _skill="$_cand"; break; }
|
||||
done
|
||||
if [ -z "$_skill" ]; then
|
||||
note "找不到技能本文,這一次沒有比對「本文叫得到的腳本有沒有全部放行」。找過 $ROOT/jsc-assist 與 $ROOT/assist 兩處。規則清單照樣印在上面,但少一支就是那一輪停在權限詢問,請自己核對一次。"
|
||||
else
|
||||
_missing=''
|
||||
# 只掃 patrol 那一節。規則防的是無人值守那一輪,而 start 與 status 兩個操作都有人在現場,
|
||||
# 它們叫的腳本跳一次權限詢問有人按得掉。整份掃的話那幾支會每次都被報成缺規則,而一份
|
||||
# 每次都有雜訊的警告,看的人第二次就會跳過它——那時候真的漏一支就沒有人看得到了。
|
||||
for _w in $(awk '/^## patrol$/{f=1;next} f && /^## /{exit} f' "$_skill" \
|
||||
| sed -n 's|.*{CURRENT}/\(jsc-[a-z-]*/[a-z-]*/[a-z-]*\.sh\).*|\1|p' | sort -u); do
|
||||
case " $_printed " in
|
||||
*" $_w "*) ;;
|
||||
*) _missing="${_missing:+$_missing、}$_w" ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$_missing" ] || note "技能本文叫得到、但上面的規則沒有涵蓋的腳本:$_missing。那一輪叫到它就會停在權限詢問,而鎖已經握在手上,接下來每一輪都會讓開到鎖逾時。請把這幾支補進這一支腳本的規則清單,再重跑一次 install。"
|
||||
fi
|
||||
}
|
||||
|
||||
# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查
|
||||
@@ -602,6 +717,28 @@ 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
|
||||
# 這一支只定義 note,沒有 warn。這裡本來寫 warn,於是殼回一句「warn: not found」而那句
|
||||
# 提醒一次都沒印出來——條目長到 973 個位元組、距上限只剩 27,而該說話的那道檢核是啞的。
|
||||
# 一道說不出話的檢核跟沒有那道檢核是同一件事,而且它連自己壞了都只在 stderr 留一行。
|
||||
if [ "$_len" -ge $((CRON_LINE_MAX - 100)) ]; then
|
||||
note "$_job 的條目有 $_len 個位元組,距離 cron 一行的上限 $CRON_LINE_MAX 不到 100。再多加一個環境變數快照就會裝不進去,而那一次的錯誤訊息只會說 command too long。條目長度大半來自 wiki 存取庫那一系列變數的快照,一支約 40 個位元組。"
|
||||
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)"
|
||||
|
||||
Executable
+713
@@ -0,0 +1,713 @@
|
||||
#!/usr/bin/env sh
|
||||
# seed-tasks.sh — 依委派清單種入與重建助理的內建定期檢查項(供 jsc-assist:assistant 呼叫)。
|
||||
#
|
||||
# 用法:
|
||||
# seed-tasks.sh plan [--root {字面絕對路徑}] [--spec {清單檔}]
|
||||
# [--allow-cond jsc-{domain}:{技能名}]... [--refresh]
|
||||
# seed-tasks.sh apply [--root {字面絕對路徑}] [--spec {清單檔}]
|
||||
# [--allow-cond jsc-{domain}:{技能名}]... [--refresh]
|
||||
#
|
||||
# plan 唯讀預覽:算出該加哪幾筆、該移除哪幾筆、哪幾筆對不上,一筆都不寫。
|
||||
# apply 真的做:該加的用 tasks.sh add 加,該移除的用 tasks.sh remove 移除。
|
||||
#
|
||||
# 結束碼:
|
||||
# 0 對齊完成。plan 印完,或 apply 做完。零筆改動照樣是 0:清單與待辦簿本來就一致,
|
||||
# 不是失敗,也不必分流
|
||||
# 1 委派清單讀不到:找不到那個檔案,或檔案在卻讀不到內容。**一筆都不動**
|
||||
# 2 清單讀到了,卻解不出任何一列可交項目。**一筆都不動**,理由見下面「空清單一律不動手」
|
||||
# 3 找不到本 domain 的 tasks.sh。**一筆都不動**:待辦簿只有那一支寫得出來
|
||||
# 4 至少一筆 add 或 remove 失敗,其餘各筆照做完。哪一筆失敗、回了哪一碼,逐筆印出來
|
||||
# 5 檔案系統失敗:暫存目錄建不起來,或暫存檔寫不進去
|
||||
# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、--allow-cond 的值形狀不對
|
||||
#
|
||||
# --- 這一支負責什麼、不負責什麼 ---
|
||||
#
|
||||
# 只負責「待辦簿裡的內建項要跟委派清單一致」這一件事。它不判到期(那是 due.sh)、不執行任何
|
||||
# 一筆待辦、不寫待辦檔(那是 tasks.sh,那一支是唯一的寫入入口,這裡一律去呼叫它,不自己動
|
||||
# tasks/ 底下的檔案)。
|
||||
# 種入與重建是同一段程式,不是兩段。理由:兩段程式各自回答「待辦簿裡該有哪幾筆」這個同一個
|
||||
# 問題,第一次答案相同看起來沒事,等其中一段改了判準,兩段就會一邊加一邊刪同一筆,每一輪
|
||||
# 反覆。一段程式做到冪等,第一次跑就是種入、之後每一次跑都是重建,行為只有一種。
|
||||
# 所以這一支不記「跑過沒有」。要不要動手完全由現況決定:清單上有、待辦簿沒有就加;待辦簿有、
|
||||
# 清單上沒有就移除;兩邊都有就留著不動。第二次跑因此什麼都不會加。
|
||||
#
|
||||
# --- 誰是同一筆:靠 spec_key,不靠 id 也不靠標題 ---
|
||||
#
|
||||
# 待辦簿的 id 是「建立時間加標題」的雜湊,跟技能名沒有關係,所以反查不了。反查鍵是待辦檔裡
|
||||
# 的 spec_key 欄位,值是 jsc-{domain}:{技能名},由 tasks.sh add 的 --spec-key 寫進去。
|
||||
# 完整理由寫在 tasks.sh 的檔頭「spec_key 為什麼非有不可」,這裡只記結論:拿標題當鍵會讓
|
||||
# 標題的措辭變成介面,拿 action 當鍵會讓提醒類的那十幾筆全部撞在一起,也會撞上使用者自己
|
||||
# 交辦、動作剛好是同一支技能的那一筆。
|
||||
#
|
||||
# --- 哪些欄位從清單來、哪些自己補 ---
|
||||
#
|
||||
# 清單有十二欄,待辦簿有十五個鍵,直接抄過來的只有兩欄。逐個交代:
|
||||
# 從清單直接抄過來
|
||||
# trigger 原樣抄。第一次什麼時候到期是判定結果,不是這一支的決定
|
||||
# recur 原樣抄。同上
|
||||
# 從清單推出來
|
||||
# spec_key domain 與 name 兩欄合起來寫成 jsc-{domain}:{技能名},那就是一支技能的身分
|
||||
# action 由 way 與 probe 兩欄一起推,對映見下一段。way 與 action 不是同一件事
|
||||
# title 固定寫成「委派清單內建項:{spec_key}」。標題刻意不含 way、trigger、recur:
|
||||
# 那幾欄會隨清單改動而變,寫進標題就等於每一次改動都換一個 id,而 id 一換,
|
||||
# last_run 與 fail_count 的歷史就跟著斷掉
|
||||
# 這一支自己補,清單裡沒有對應欄位
|
||||
# kind 一律 check。清單管的是「定期做的事」,交辦事項是使用者當面給的,不從清單來
|
||||
# origin 一律 assistant。這一欄決定重建時動不動它,所以不能空、也不能是 user
|
||||
# repo 一律留空。這幾項是機器層級的檢查,不綁單一存取庫;要掃存取庫的那幾項由助理
|
||||
# 自己掃工作目錄底下所有存取庫,不逐筆綁路徑
|
||||
# due 一律留空。清單沒有截止時間這一欄,補一個猜出來的截止時間會讓監控頁標出一批
|
||||
# 沒有人約定過的逾期。留空是合法狀態,意思是沒有截止時間
|
||||
# 不抄過來的清單欄位
|
||||
# verdict 只用來決定要不要種入,見下面「條件式交的那幾支一律不種入」
|
||||
# slice、human 是給人讀的判定說明,逐字抄進標題會讓標題長到在狀態表上看不完;要看它們
|
||||
# 就去看清單本身
|
||||
# next 跑完之後建議接哪一支,那是建議下一個指令那一項要用的資料,跟排程無關
|
||||
# version、origin 清單自己的稽核欄位。清單的 origin 是 seed 或 judged,講的是「這一列
|
||||
# 判定過沒有」;待辦簿的 origin 是 user 或 assistant,講的是「這一筆誰交辦的」。
|
||||
# 兩個同名不同義,一律不互抄
|
||||
# state、last_run、next_run、fail_count 由 tasks.sh 與判到期那一邊維護,這一支不碰。
|
||||
#
|
||||
# --- way 與 probe 怎麼推出 action ---
|
||||
#
|
||||
# 清單的 way 是交出方式,三種:invoke 觸發、patrol 巡檢、remind 提醒。待辦簿的 action 是
|
||||
# 助理實際要跑的事,三種:技能名、指令、remind。兩套詞彙不對應,所以要明寫對映:
|
||||
# way 含 invoke → action 取技能名,probe 一律不看。觸發的定義就是呼叫既有技能、內容
|
||||
# 照那支技能自己的流程走,所以填技能名就是照判定結果做
|
||||
# 其餘那幾種(patrol、remind、patrol,remind)看 probe 那一欄:
|
||||
# probe 是一行指令 → action 取那一行指令,代入點先代好
|
||||
# probe 是 pending:{理由} → action 取 remind,另外印一行 pending=
|
||||
# probe 是減號或空的 → action 取 remind
|
||||
# 中間那一條原本沒有:這一支以前把非 invoke 的列一律推成 remind,因為那些唯讀切片沒有獨立
|
||||
# 的入口——巡檢那一輪讀的是固定那幾項來源,沒有一支腳本代表得了「某一支技能的唯讀切片」。
|
||||
# 清單補上 probe 之後,有入口的那幾列指得出來了,所以改成照 probe 走。沒有入口的那幾列行為
|
||||
# 一個字都沒變,還是 remind:填技能名會讓助理下一輪去呼叫整支技能,那正是切片交要防的事,
|
||||
# 留在人手上的那一半會被一路跑完。
|
||||
# invoke 那一路為什麼連看都不看 probe:填了指令會讓整支交出變成只跑一支腳本,那支技能該寫
|
||||
# 的頁一頁都不會寫,而且看起來完全正常。清單自己的檔頭也明寫 invoke 的列一律填減號,所以
|
||||
# 那是清單填錯,不是這裡要順從的設定。這一支照 way 取技能名、另外印一行 probe_bad= 報出來,
|
||||
# 不因為那個錯就不種入——不種入等於讓上游一個打錯的欄位把一筆好好的內建項刪掉。
|
||||
#
|
||||
# --- probe 代不進去的時候一律退回 remind ---
|
||||
#
|
||||
# probe 有問題的處置只有一種:**退回 remind,另外印一行 probe_bad=,照樣種入那一筆。**
|
||||
# 涵蓋這幾種:路徑不是 {root}/jsc-{domain}/ 開頭、指令裡有金錢符號或波浪號(那兩種在無人
|
||||
# 值守那一輪解不出來,也進不了允許清單)、出現 {root}、{cli}、{repo} 以外的代入點、代不出
|
||||
# 根目錄,還有那支腳本不在這台機器上。
|
||||
# 為什麼不改成「不種入」:不種入在 apply 那一路等於移除,於是上游改壞一格就會把一筆帶著
|
||||
# last_run 與 fail_count 的內建項刪掉。退回 remind 的代價只是那一筆這一輪不動手,人照樣被
|
||||
# 提醒該做那一段,而 probe_bad= 那幾行指得出是哪一支、壞在哪。
|
||||
# 為什麼不把代不出來的指令原樣種進去:那個值最後會被拿去執行。帶著 {root} 或 {cli} 的字面值
|
||||
# 執行起來就是指到一個不存在的路徑,每一輪失敗一次;帶著金錢符號的那一種更糟,展開之後跑到
|
||||
# 哪裡去沒有人說得準。
|
||||
#
|
||||
# --- {cli} 與 {repo} 留在值裡,執行那一步才代 ---
|
||||
#
|
||||
# probe 認得三個代入點。{root} 這一支自己代得掉,另外兩個代不掉:{cli} 要代入助理偵測到的
|
||||
# CLI 代號,{repo} 要代入助理掃到的存取庫工作目錄,兩件事種入的當下都還不知道。
|
||||
# 兩條路,這裡選第二條:
|
||||
# 一、種入時就展開成多筆。代價是一致化整個垮掉。反查鍵 spec_key 是一支技能一個值,展開成
|
||||
# 多筆就是同一個鍵有好幾個檔案,而這一支碰到同一個鍵有兩筆以上一律不動它、只印 dup=,
|
||||
# 於是每一輪都印一堆 dup=、一筆都對不齊。而且 CLI 裝了新的一支、存取庫多 clone 一個,
|
||||
# 那組展開就過期了,要重新展開就得先移除再重新登錄,last_run 與 fail_count 跟著歸零,
|
||||
# 那正是下面 drift 那一段刻意不做的事。
|
||||
# 二、留著代入點,執行那一步再展開。代價是待辦簿裡留著一個還沒代進去的字面值。
|
||||
# 選二,並且把代價擋掉:**action 裡出現大括號就是還沒代好的代入點,一律不得原樣拿去執行。**
|
||||
# 執行那一步(現在還沒接上來,見技能本文)要先把 {cli} 換成每一支偵測到的 CLI 代號、把
|
||||
# {repo} 換成每一個掃到的存取庫工作目錄,一個目標跑一次,那一輪所有目標的結果合起來算這一筆
|
||||
# 的一次成敗。展開由執行那一步負責,不由這一支、也不由 tasks.sh 負責。
|
||||
# 其他讀取端會不會誤解,逐個交代:tasks.sh 只存放,action 對它是不透明的一個字串;due.sh
|
||||
# 只判到期,它讀 action 是為了印出來,一次都不執行;技能的 status 與監控頁也只是把 action
|
||||
# 原樣印出來。三個讀取端沒有一個會拿 action 去跑,所以現在留著代入點不會有人跑錯;真正要防
|
||||
# 的是往後接執行那一步的人,那一條規則寫在上面那一句,也寫進技能本文與行為清單。
|
||||
# 種入時每一筆指令型都印一行 probe=,holes= 那一欄列出這一筆還留著哪幾個代入點,所以留了
|
||||
# 什麼看得見,不必去讀待辦檔才知道。
|
||||
#
|
||||
# --- pending 的那幾筆只印不寫 ---
|
||||
#
|
||||
# probe 是 pending:{理由} 的那幾列,action 照舊取 remind,但要跟「本來就只提醒」分得開。
|
||||
# 分法是**只印在回報裡**:一行 pending={spec_key} reason={清單上的理由原文},摘要另外印
|
||||
# pending= 的筆數。待辦檔本身一個字都不加。
|
||||
# 為什麼不寫進標題:標題是 id 的雜湊來源,改標題就換 id,last_run 與 fail_count 跟著斷掉;
|
||||
# 而且這一支的漂移比對只比 kind、action、trigger、recur 四欄,不比標題,所以一個寫進標題的
|
||||
# 記號在 pending 換成指令、或換成減號的時候不會被比出來,會一直留在那裡指著一件已經不成立
|
||||
# 的事。
|
||||
# 為什麼不另立一個欄位:那是待辦簿的存放格式,十五個鍵是固定的,加一個鍵要動 tasks.sh 的
|
||||
# 寫入、驗證、list 欄位順序,還有每一個讀 list 的人。而 pending 的理由是清單上的散文,會隨
|
||||
# 上游改,待辦簿又沒有 edit 操作,抄進去就是抄了一份改不掉的舊值。清單的 verdict、slice、
|
||||
# human 幾欄不抄進待辦簿也是同一個理由。
|
||||
# 只印在回報裡夠不夠用:夠。人要看的入口就是 status,那一個操作跑的是這一支的 plan,
|
||||
# pending= 與 held=、drift= 印在同一個地方,一次看完。
|
||||
#
|
||||
# --- 清單只有十一欄的時候 ---
|
||||
#
|
||||
# probe 是清單的第十二欄,比這一支晚不了也早不了:兩個存取庫各自合併,總有一邊先到。所以
|
||||
# 十一欄的舊清單餵進來要照常跑完,行為與加上這一欄之前一模一樣。
|
||||
# 判法是先數一次資料列的欄位數,取最大值當這一份清單的形狀:
|
||||
# 最大欄位數 12 以上 → 這一份有 probe,照上面那幾段走;某一列少一格導致 probe 是空的,
|
||||
# 那是那一列漏填,退回 remind 並印 probe_bad=
|
||||
# 最大欄位數 11 以下 → 這一份沒有 probe,全部照 way 推 action,一行 note 講明這一輪為什麼
|
||||
# 沒有任何一筆指令型,不逐列印 probe_bad=——三十五行同一個原因的警告
|
||||
# 會把真的缺失蓋掉
|
||||
# 取最大值而不是逐列各判:清單是一份檔案,欄位數是整份的性質。逐列各判的話,十二欄清單裡
|
||||
# 一列漏填會被當成「這一列是舊格式」而靜靜放過,那正是要抓出來的東西。
|
||||
# 第十三欄以後留給往後:讀的時候另外接一個變數收尾,多出來的欄位不會被黏進 probe。少了這個
|
||||
# 收尾,上游哪天加第十三欄,probe 讀到的就是「指令加一個定位字元加第十三欄」,代入點檢查
|
||||
# 全部過得了關,最後拿去執行的是一行誰都沒寫過的指令。
|
||||
#
|
||||
# --- 條件式交的那幾支一律不種入 ---
|
||||
#
|
||||
# 判定是 cond 的那幾列,預設一筆都不種入,並且逐列印一行 held=,把清單上的條件原文帶出來。
|
||||
# 理由是條件本身是散文,程式判不了。清單裡沒有一個機器讀得懂的條件欄位,只有 slice 與 human
|
||||
# 兩欄的說明文字,所以「條件成立了沒有」這件事現在只有人答得出來。
|
||||
# 預設種入的代價已經有現成的例子:其中一支健檢技能的條件明寫「只有在它自家路徑不再帶版本號
|
||||
# 之後才可以交」,而那個條件現在不成立——真的種進去,助理每一輪都會去叫它,那一輪會停在
|
||||
# 第一支自家腳本上,因為帶版本號的路徑進不了允許清單,而且是無聲的:沒有錯誤、沒有輸出,
|
||||
# 心跳照寫,看起來完全正常。一個會無聲卡死的項目比一個缺掉的項目難查得多。
|
||||
# 預設不種入的代價是「少了它,看不出為什麼」,那個代價用 held= 那幾行補掉:每一列印出技能名、
|
||||
# 判定、條件原文與留在人手上的那一半,摘要另外印 held= 的筆數。少掉的那幾支看得見、也看得出
|
||||
# 是刻意少的,不是漏的。
|
||||
# 人確認過某一支的條件成立了,就帶 --allow-cond jsc-{domain}:{技能名} 種入那一支,一支一支帶,
|
||||
# 不給一個「全部放行」的旗標:全部放行等於用一個決定蓋掉三個各自不同的條件。
|
||||
#
|
||||
# --- 清單改了 trigger、recur 或 way 怎麼辦 ---
|
||||
#
|
||||
# 規格的重建規則只有三條:清單新增可交項目就加一筆、清單移除或改成不交就移除那一筆、
|
||||
# origin=user 的一律不動。一支技能還在清單上、還是可交,只是 trigger、recur 或 way 換了值,
|
||||
# 三條規則一條都沒講到。這一支的處置是:**預設只印 drift= 報出來,不改那一筆。**
|
||||
# 兩個理由。一、tasks.sh 刻意沒有 edit 操作,改欄位值只能移除再重新登錄,而重新登錄會換一個
|
||||
# 新的 id,last_run 與 fail_count 從此歸零——一個原本已經連續失敗五次的項目會看起來像全新的,
|
||||
# 沒有人會知道它壞了。二、規格沒講的事不自己補一條規則進去,尤其是會弄丟資料的那一種。
|
||||
# 印出來,人看得到,要不要換由人決定。
|
||||
# 人決定要換就帶 --refresh:那一筆先 remove 再 add,並且明說歷史會歸零。
|
||||
#
|
||||
# --- 空清單一律不動手 ---
|
||||
#
|
||||
# 清單讀不到(回 1)或讀到了卻解不出任何一列可交項目(回 2),這一支一筆都不移除,直接回報。
|
||||
# 這一條是刻意寫的例外,不是漏掉:一致化的邏輯照字面跑,「清單上沒有」就等於「該移除」,
|
||||
# 於是一個沒裝 jsc-meta 的機器、一個 clone 到一半的存取庫、一個被改壞的清單檔,都會讓這一支
|
||||
# 把所有內建項一次刪光,而且回 0 看起來完全成功。刪光之後助理就再也不做任何定期檢查了,
|
||||
# 而待辦簿上什麼都沒有,看不出曾經有過。
|
||||
# 所以「清單是空的」與「清單說沒有可交項目」在這裡當成兩件事:前者是讀取失敗,一律不動手。
|
||||
#
|
||||
# --- 使用者交辦的那幾筆 ---
|
||||
#
|
||||
# origin=user 的待辦一律不動,這一支連讀都只讀不比對:不加、不移除、不算進 drift。
|
||||
# 擋在兩層。這一支自己跳過那幾筆,而 tasks.sh 的 remove 對 origin=user 一律回 7,除非人親自
|
||||
# 帶 --force——這一支一次都不帶那個旗標。手改過的檔案有可能出現 origin=user 卻帶著 spec_key
|
||||
# 的組合(tasks.sh add 擋得住這個組合,手寫檔案擋不住),那幾筆印一行 skip_user= 並跳過,
|
||||
# 不當成孤兒移除。
|
||||
#
|
||||
# 環境變數:
|
||||
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜
|
||||
# JSC_DELEGATE_SPEC 委派清單的路徑,優先於 --root 與自動搜尋
|
||||
# JSC_TASKS_SH 本 domain 的 tasks.sh 路徑,優先於自動搜尋
|
||||
set -u
|
||||
|
||||
JSC_HOME_RAW="${JSC_HOME:-}"
|
||||
if [ -z "$JSC_HOME_RAW" ]; then
|
||||
JSC_HOME_RAW="${HOME:-}"
|
||||
[ -n "$JSC_HOME_RAW" ] || {
|
||||
printf '[jsc][助理內建項][ERR]:JSC_HOME 與 HOME 都沒有設定,找不到待辦簿的根目錄。這裡不猜一個路徑:猜錯就是拿另一個地方的待辦簿去對清單,於是把該有的那幾筆全部當成缺的再加一次。請設定 JSC_HOME 再跑一次。\n' >&2
|
||||
exit 6
|
||||
}
|
||||
JSC_HOME_RAW="$JSC_HOME_RAW/.jsc"
|
||||
fi
|
||||
case "$JSC_HOME_RAW" in
|
||||
/*) ;;
|
||||
*)
|
||||
_abs=$(CDPATH= cd -- "$JSC_HOME_RAW" 2>/dev/null && pwd -L) || _abs=''
|
||||
[ -n "$_abs" ] || {
|
||||
printf '[jsc][助理內建項][ERR]:JSC_HOME 是相對路徑(%s),也解不出絕對路徑。待辦簿的位置必須是字面絕對路徑,請把 JSC_HOME 設成絕對路徑再跑一次。\n' "$JSC_HOME_RAW" >&2
|
||||
exit 6
|
||||
}
|
||||
JSC_HOME_RAW="$_abs" ;;
|
||||
esac
|
||||
|
||||
JSC_HOME="$JSC_HOME_RAW"
|
||||
CURRENT="$JSC_HOME/current"
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
||||
SCRIPT_DIR="${SCRIPT_DIR:-.}"
|
||||
|
||||
TAB=$(printf '\t')
|
||||
|
||||
die() { # $1=結束碼 $2=訊息
|
||||
printf '[jsc][助理內建項][ERR]:%s\n' "$2" >&2
|
||||
exit "$1"
|
||||
}
|
||||
warn() { printf '[jsc][助理內建項][WARN]:%s\n' "$1" >&2; }
|
||||
note() { printf '[jsc][助理內建項]:%s\n' "$1" >&2; }
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: seed-tasks.sh plan [--root 字面絕對路徑] [--spec 清單檔]
|
||||
[--allow-cond jsc-{domain}:{技能名}]... [--refresh]
|
||||
seed-tasks.sh apply [--root 字面絕對路徑] [--spec 清單檔]
|
||||
[--allow-cond jsc-{domain}:{技能名}]... [--refresh]
|
||||
EOF
|
||||
exit 6
|
||||
}
|
||||
|
||||
# 判準與處置同這個 domain 的其他腳本:只警告、照跑。從工作樹直接跑是開發時的正當用法。
|
||||
warn_if_not_current() {
|
||||
_want="$CURRENT/jsc-assist/tools/$(basename -- "$0")"
|
||||
case "$SCRIPT_DIR/" in
|
||||
"$CURRENT"/*) return 0 ;;
|
||||
esac
|
||||
warn "這支腳本是從 $SCRIPT_DIR/$(basename -- "$0") 跑起來的,不是 $_want。權限閘門只放行 current 那一組確切路徑:無人值守那一輪用別的路徑會被靜靜擋掉。開發時這樣跑沒關係。"
|
||||
return 0
|
||||
}
|
||||
warn_if_not_current
|
||||
|
||||
valid_spec_key() {
|
||||
printf '%s' "$1" | LC_ALL=C grep -qE '^jsc-[a-z0-9-]+:[a-z0-9-]+$'
|
||||
}
|
||||
|
||||
# --- 找委派清單 ---
|
||||
|
||||
# 根目錄優先取 --root 給的字面絕對路徑,理由與技能本文取根目錄的規則相同:無人值守那一輪
|
||||
# 自己不解根目錄,值由裝排程的人寫進條目、再一路餵下來。呼叫端沒給才自己找,找的順序比照
|
||||
# patrol.sh 的 find_tool():先環境變數覆寫,再 current 那一組連結,然後開發用的並排存取庫
|
||||
# 版面,最後已安裝的快取版面。
|
||||
find_spec() {
|
||||
if [ -n "${JSC_DELEGATE_SPEC:-}" ]; then
|
||||
[ -f "$JSC_DELEGATE_SPEC" ] && { printf '%s\n' "$JSC_DELEGATE_SPEC"; return 0; }
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$OPT_SPEC" ]; then
|
||||
[ -f "$OPT_SPEC" ] && { printf '%s\n' "$OPT_SPEC"; return 0; }
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$OPT_ROOT" ]; then
|
||||
for _c in "$OPT_ROOT/jsc-meta/tools/delegate-spec.tsv" "$OPT_ROOT/meta/tools/delegate-spec.tsv"; do
|
||||
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
for _c in "$CURRENT/jsc-meta/tools/delegate-spec.tsv" "$CURRENT/meta/tools/delegate-spec.tsv"; do
|
||||
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
done
|
||||
_root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}"
|
||||
for _c in "$_root/../meta/tools/delegate-spec.tsv" "$_root/../jsc-meta/tools/delegate-spec.tsv"; do
|
||||
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
done
|
||||
_c=$(ls -d "$_root"/../../jsc-meta/*/tools/delegate-spec.tsv \
|
||||
"$_root"/../../meta/*/tools/delegate-spec.tsv \
|
||||
"$HOME"/.claude/plugins/cache/*/jsc-meta/*/tools/delegate-spec.tsv 2>/dev/null \
|
||||
| sort | tail -n1)
|
||||
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
# tasks.sh 一律取這一支腳本旁邊那一份,不去 current 或快取裡另外挑:兩支同 domain 的腳本
|
||||
# 混到不同版本,欄位順序或結束碼一不一樣都看不出來,而寫入的是待辦簿本身。
|
||||
find_tasks_sh() {
|
||||
if [ -n "${JSC_TASKS_SH:-}" ]; then
|
||||
[ -f "$JSC_TASKS_SH" ] && { printf '%s\n' "$JSC_TASKS_SH"; return 0; }
|
||||
return 1
|
||||
fi
|
||||
[ -f "$SCRIPT_DIR/tasks.sh" ] && { printf '%s\n' "$SCRIPT_DIR/tasks.sh"; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- way 與 probe 對 action ---
|
||||
|
||||
# way 是半形逗號隔開的清單,比對時前後各補一個逗號,才不會讓 invoke 去命中一個叫別的名字
|
||||
# 但含有 invoke 這幾個字的交出方式。
|
||||
is_invoke() { # $1=way
|
||||
case ",$1," in
|
||||
*,invoke,*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# 代入 {root} 用的字面絕對根目錄,整份清單只解一次,值放在 PROBE_ROOT。--root 給了就用那
|
||||
# 一個,理由同 find_spec():根目錄由呼叫端餵進來,這一支不自己解也不猜。沒給才從清單自己的
|
||||
# 位置往上推三層——清單一定在 {根目錄}/jsc-meta/tools/delegate-spec.tsv,推得回去;--spec
|
||||
# 指到別處時推出來的值不一定對,所以每一列代完還要看那一層底下有沒有對應的 domain 目錄。
|
||||
resolve_root() {
|
||||
if [ -n "$OPT_ROOT" ]; then
|
||||
PROBE_ROOT="$OPT_ROOT"
|
||||
return 0
|
||||
fi
|
||||
PROBE_ROOT=$(CDPATH= cd -- "$(dirname -- "$SPEC")/../.." 2>/dev/null && pwd -L) || PROBE_ROOT=''
|
||||
return 0
|
||||
}
|
||||
|
||||
# 把 probe 那一行指令代好代入點。設好 PROBE_CMD 與 PROBE_HOLES 回 0,代不出來就設好
|
||||
# PROBE_WHY 回 1。處置一律是退回 remind,理由見檔頭「probe 代不進去的時候一律退回 remind」。
|
||||
# 這一支刻意用設全域變數的寫法、不用命令替換接回傳值:命令替換是子行程,裡面設的
|
||||
# PROBE_HOLES 與 PROBE_WHY 傳不回來,於是每一筆的 holes= 與失敗理由都會是空的。
|
||||
probe_action() { # $1=probe 原文
|
||||
PROBE_CMD=''
|
||||
PROBE_HOLES=''
|
||||
PROBE_WHY=''
|
||||
# 金錢符號與波浪號先擋。那兩種寫法在無人值守那一輪解不出來,也進不了允許清單,會被靜靜
|
||||
# 擋掉;擋在種入這一刻,錯的是清單這件事才看得見。
|
||||
case "$1" in
|
||||
*'$'*) PROBE_WHY='指令裡有金錢符號。那種寫法在無人值守那一輪解不出來,也進不了允許清單,會被靜靜擋掉'; return 1 ;;
|
||||
*'~'*) PROBE_WHY='指令裡有波浪號。那種寫法在無人值守那一輪解不出來,也進不了允許清單,會被靜靜擋掉'; return 1 ;;
|
||||
esac
|
||||
# 腳本路徑一律寫成 {root}/jsc-{domain}/ 開頭。認不出這個形狀就代不出絕對路徑,而權限閘門
|
||||
# 只放行完整字面絕對路徑。
|
||||
_dom=$(printf '%s' "$1" | LC_ALL=C sed -n 's|.*{root}/jsc-\([a-z0-9-]*\)/.*|\1|p')
|
||||
if [ -z "$_dom" ]; then
|
||||
PROBE_WHY='路徑不是 {root}/jsc-{domain}/ 開頭,代不出字面絕對路徑'
|
||||
return 1
|
||||
fi
|
||||
if [ -z "$PROBE_ROOT" ]; then
|
||||
PROBE_WHY="代不出根目錄(清單在 $SPEC,也沒有帶 --root)"
|
||||
return 1
|
||||
fi
|
||||
# 那一層的目錄名以 jsc-{domain} 為準,找不到就退回不帶前綴的 {domain},比照 find_spec()
|
||||
# 找清單本身的作法:開發用的並排存取庫版面那一層就是不帶前綴的。
|
||||
if [ -d "$PROBE_ROOT/jsc-$_dom" ]; then
|
||||
_dir="jsc-$_dom"
|
||||
elif [ -d "$PROBE_ROOT/$_dom" ]; then
|
||||
_dir="$_dom"
|
||||
else
|
||||
PROBE_WHY="這台機器的 $PROBE_ROOT 底下找不到 jsc-$_dom,也找不到 $_dom,那一段唯讀盤點的腳本不在這裡"
|
||||
return 1
|
||||
fi
|
||||
_pre="${1%%\{root\}/jsc-$_dom/*}"
|
||||
_post="${1#*\{root\}/jsc-$_dom/}"
|
||||
_cmd="$_pre$PROBE_ROOT/$_dir/$_post"
|
||||
# 指到不存在的腳本算缺失。種進去的話那一筆每一輪失敗一次,而失敗的原因在清單那一邊。
|
||||
_script="$PROBE_ROOT/$_dir/${_post%% *}"
|
||||
if [ ! -f "$_script" ]; then
|
||||
PROBE_WHY="代出來的腳本不存在:$_script"
|
||||
return 1
|
||||
fi
|
||||
# {root} 代完之後還准留的大括號只有 {cli} 與 {repo} 兩種。其餘一律算填錯:代不進去的字面值
|
||||
# 會原樣送進指令。
|
||||
_left=$(printf '%s' "$_cmd" | sed 's/{cli}//g; s/{repo}//g')
|
||||
case "$_left" in
|
||||
*'{'*|*'}'*)
|
||||
PROBE_WHY="代完 {root} 之後還留著認不得的代入點:$_cmd"
|
||||
return 1 ;;
|
||||
esac
|
||||
case "$_cmd" in
|
||||
*'{cli}'*) PROBE_HOLES='{cli}' ;;
|
||||
esac
|
||||
case "$_cmd" in
|
||||
*'{repo}'*) PROBE_HOLES="${PROBE_HOLES:+$PROBE_HOLES,}{repo}" ;;
|
||||
esac
|
||||
PROBE_CMD="$_cmd"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 參數 ---
|
||||
|
||||
MODE="${1:-}"
|
||||
[ -n "$MODE" ] || usage
|
||||
case "$MODE" in
|
||||
plan|apply) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
shift
|
||||
|
||||
OPT_ROOT=''
|
||||
OPT_SPEC=''
|
||||
OPT_REFRESH=0
|
||||
ALLOW_COND=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT="$2"; shift 2 ;;
|
||||
--spec) [ "$#" -ge 2 ] || usage; OPT_SPEC="$2"; shift 2 ;;
|
||||
--refresh) OPT_REFRESH=1; shift ;;
|
||||
--allow-cond)
|
||||
[ "$#" -ge 2 ] || usage
|
||||
valid_spec_key "$2" || die 6 "--allow-cond「$2」不是 jsc-{domain}:{技能名} 這個形狀。形狀不對的值放行不了任何一支,而那一支會被當成沒放行、靜靜少掉。"
|
||||
ALLOW_COND="$ALLOW_COND $2"
|
||||
shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$OPT_ROOT" in
|
||||
''|/*) ;;
|
||||
*) die 6 "--root 給的是「$OPT_ROOT」,不是絕對路徑。根目錄由呼叫端餵進來,這一支不自己解、也不猜:相對路徑在排程那一輪等於指向排程機制的工作目錄。" ;;
|
||||
esac
|
||||
|
||||
allowed_cond() { # $1=spec_key
|
||||
for _a in $ALLOW_COND; do
|
||||
[ "$_a" = "$1" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- 前置 ---
|
||||
|
||||
SPEC=$(find_spec) || SPEC=''
|
||||
[ -n "$SPEC" ] || die 1 "找不到委派清單(jsc-meta 的 tools/delegate-spec.tsv)。這一支一筆都不動:讀不到清單的時候,「清單上沒有」與「這台機器沒裝 jsc-meta」分不出來,照字面跑會把所有內建項一次刪光。請安裝 jsc-meta,或用 --root 指定工具根目錄、用 --spec 直接指定清單檔。"
|
||||
[ -r "$SPEC" ] || die 1 "委派清單在 $SPEC,可是讀不到。一筆都不動,理由同上。請檢查那個檔案的權限。"
|
||||
|
||||
TASKS_SH=$(find_tasks_sh) || TASKS_SH=''
|
||||
[ -n "$TASKS_SH" ] || die 3 "找不到本 domain 的 tasks.sh(找過 $SCRIPT_DIR)。待辦簿只有那一支寫得出來,這一支不自己動 tasks/ 底下的檔案,所以這一次一筆都不動。"
|
||||
|
||||
TMPD=$(mktemp -d 2>/dev/null) || die 5 '建不出暫存目錄,這一輪算不出要改哪幾筆。'
|
||||
trap 'rm -rf "$TMPD"' EXIT
|
||||
WANT="$TMPD/want.tsv"
|
||||
HAVE="$TMPD/have.tsv"
|
||||
: >"$WANT" 2>/dev/null || die 5 "暫存檔寫不進去:$WANT。"
|
||||
: >"$HAVE" 2>/dev/null || die 5 "暫存檔寫不進去:$HAVE。"
|
||||
|
||||
# --- 讀清單,算出「該有哪幾筆」 ---
|
||||
|
||||
N_HELD=0
|
||||
N_BAD=0
|
||||
N_PROBE=0
|
||||
N_PENDING=0
|
||||
N_PROBE_BAD=0
|
||||
PROBE_ROOT=''
|
||||
PROBE_CMD=''
|
||||
PROBE_HOLES=''
|
||||
PROBE_WHY=''
|
||||
resolve_root
|
||||
|
||||
# 這一份清單有沒有 probe 那一欄,先數欄位數判一次,理由與判法見檔頭「清單只有十一欄的
|
||||
# 時候」。註解列不算:檔頭那幾行的欄位數是說明文字切出來的,跟資料列無關。
|
||||
SPEC_COLS=$(awk -F"$TAB" '/^#/{next} NF>1 {if (NF>m) m=NF} END{print m+0}' "$SPEC" 2>/dev/null)
|
||||
case "$SPEC_COLS" in
|
||||
''|*[!0-9]*) SPEC_COLS=0 ;;
|
||||
esac
|
||||
if [ "$SPEC_COLS" -lt 12 ]; then
|
||||
note "委派清單 $SPEC 只有 $SPEC_COLS 欄,沒有第十二欄 probe。這一輪所有非 invoke 的列照舊一律種成只提醒,行為與清單補上那一欄之前相同。要讓有唯讀盤點入口的那幾列真的動手,請先把 jsc-meta 更新到有 probe 那一欄的版本,再跑一次 apply——那一次那幾筆會印成 drift=,要換值得帶 --refresh。"
|
||||
fi
|
||||
|
||||
# 註解列與空白列跳掉。清單是定位字元分隔,欄位順序見清單自己的檔頭。
|
||||
# c_rest 收第十三欄以後:少了它,上游哪天加一欄,多出來的值會連著定位字元黏進 c_probe,
|
||||
# 而黏出來的那一行看起來還很像一個完整的指令。
|
||||
while IFS="$TAB" read -r c_domain c_name c_verdict c_way c_slice c_human c_trigger c_recur \
|
||||
c_next c_version c_origin c_probe c_rest; do
|
||||
case "$c_domain" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
[ -n "$c_name" ] || continue
|
||||
_key="jsc-$c_domain:$c_name"
|
||||
case "$c_verdict" in
|
||||
none)
|
||||
# 不交的那幾列本來就不該在待辦簿裡,連 held 都不算:它們是判定過決定不交,不是條件
|
||||
# 還沒成立。清單上沒有它們,重建時對應那一筆就會被移除,那正是規格要的行為。
|
||||
continue ;;
|
||||
full|slice) ;;
|
||||
cond)
|
||||
if allowed_cond "$_key"; then
|
||||
note "$_key 是條件式交,這一次由 --allow-cond 放行,照 full 與 slice 同一套種入。條件成立與否由帶這個旗標的人負責。"
|
||||
else
|
||||
# 放行過的那幾支不算保留:摘要的 held= 要等於「這一次少掉幾支」,把放行的也算進去,
|
||||
# 那個數字就跟上面的 held= 行數對不起來。
|
||||
N_HELD=$((N_HELD + 1))
|
||||
printf 'held=%s verdict=cond way=%s\n' "$_key" "$c_way"
|
||||
printf ' 條件:%s\n' "$c_slice"
|
||||
printf ' 留在人手上:%s\n' "$c_human"
|
||||
continue
|
||||
fi ;;
|
||||
*)
|
||||
N_BAD=$((N_BAD + 1))
|
||||
printf 'bad=%s reason=判定欄的值是「%s」,不在 full、slice、cond、none 四個裡面\n' "$_key" "$c_verdict"
|
||||
continue ;;
|
||||
esac
|
||||
# 清單說可交,trigger 或 recur 卻沒有值,這一筆種不出來:補一個猜出來的時程等於讓助理
|
||||
# 拿一個沒有人同意過的排程去跑。印出來讓人回去補清單。
|
||||
if [ -z "$c_trigger" ] || [ "$c_trigger" = '-' ] || [ -z "$c_recur" ] || [ "$c_recur" = '-' ]; then
|
||||
N_BAD=$((N_BAD + 1))
|
||||
printf 'bad=%s reason=判定是 %s 卻沒有 trigger 或 recur(trigger=%s recur=%s),種不出來\n' \
|
||||
"$_key" "$c_verdict" "${c_trigger:--}" "${c_recur:--}"
|
||||
continue
|
||||
fi
|
||||
# way 決定 action 的大方向,probe 只在「不是 invoke」那一路上改動它。對映見檔頭
|
||||
# 「way 與 probe 怎麼推出 action」。
|
||||
_probe="$c_probe"
|
||||
# 十一欄的舊清單一律當成沒有 probe,不逐列印警告:上面已經整份講過一次。
|
||||
[ "$SPEC_COLS" -ge 12 ] || _probe=''
|
||||
if is_invoke "$c_way"; then
|
||||
_action="$_key"
|
||||
case "$_probe" in
|
||||
''|'-') ;;
|
||||
*)
|
||||
N_PROBE_BAD=$((N_PROBE_BAD + 1))
|
||||
printf 'probe_bad=%s reason=way 含 invoke,這一次照 way 取技能名,probe 沒有採用 probe=%s\n' \
|
||||
"$_key" "$_probe" ;;
|
||||
esac
|
||||
else
|
||||
_action=remind
|
||||
case "$_probe" in
|
||||
''|'-') ;;
|
||||
'pending:')
|
||||
# 冒號後面留白的話,下一輪分不出是刻意不接還是漏填,所以當成填錯報出來,動作照樣
|
||||
# 退回只提醒。
|
||||
N_PROBE_BAD=$((N_PROBE_BAD + 1))
|
||||
printf 'probe_bad=%s reason=probe 寫了 pending 卻沒有寫理由,分不出是刻意不接還是漏填\n' "$_key" ;;
|
||||
pending:*)
|
||||
N_PENDING=$((N_PENDING + 1))
|
||||
printf 'pending=%s reason=%s\n' "$_key" "${_probe#pending:}" ;;
|
||||
*)
|
||||
if probe_action "$_probe"; then
|
||||
_action="$PROBE_CMD"
|
||||
N_PROBE=$((N_PROBE + 1))
|
||||
printf 'probe=%s holes=%s action=%s\n' "$_key" "${PROBE_HOLES:--}" "$PROBE_CMD"
|
||||
else
|
||||
N_PROBE_BAD=$((N_PROBE_BAD + 1))
|
||||
printf 'probe_bad=%s reason=%s probe=%s\n' "$_key" "$PROBE_WHY" "$_probe"
|
||||
fi ;;
|
||||
esac
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$_key" 'check' "委派清單內建項:$_key" "$_action" \
|
||||
"$c_trigger" "$c_recur" >>"$WANT" 2>/dev/null \
|
||||
|| die 5 "暫存檔寫不進去:$WANT。"
|
||||
done <"$SPEC"
|
||||
|
||||
N_WANT=$(awk 'END{print NR+0}' "$WANT")
|
||||
if [ "$N_WANT" -eq 0 ]; then
|
||||
printf 'mode=%s spec=%s want=0 held=%s bad=%s\n' "$MODE" "$SPEC" "$N_HELD" "$N_BAD"
|
||||
die 2 "委派清單 $SPEC 讀到了,卻一列可交項目都解不出來(保留 $N_HELD 列、對不上 $N_BAD 列)。一筆都不移除:一份被改壞或抄到一半的清單照字面跑會把所有內建項刪光,而那之後助理就不再做任何定期檢查,待辦簿上也看不出曾經有過。請先確認清單本身。"
|
||||
fi
|
||||
|
||||
# --- 讀待辦簿,算出「現在有哪幾筆」 ---
|
||||
|
||||
# list 的欄位順序:id、kind、state、title、action、trigger、recur、repo、due、last_run、
|
||||
# next_run、fail_count、origin、spec_key。這裡只留帶 spec_key 的那幾筆,其餘與清單無關。
|
||||
if ! "$TASKS_SH" list --no-header >"$TMPD/list.tsv" 2>"$TMPD/list.err"; then
|
||||
cat "$TMPD/list.err" >&2
|
||||
die 3 "$TASKS_SH list 回了非 0,讀不出待辦簿現況。一筆都不動:讀不到現況的時候,每一筆都會被當成缺的再加一次。"
|
||||
fi
|
||||
awk -F"$TAB" 'NF>=14 && $14!=""{printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",$14,$1,$13,$2,$4,$5,$6,$7}' \
|
||||
"$TMPD/list.tsv" >"$HAVE" 2>/dev/null || die 5 "暫存檔寫不進去:$HAVE。"
|
||||
|
||||
N_SKIP_USER=0
|
||||
N_DUP=0
|
||||
|
||||
# --- 一致化 ---
|
||||
|
||||
N_ADD=0
|
||||
N_REMOVE=0
|
||||
N_KEEP=0
|
||||
N_DRIFT=0
|
||||
RC_PARTIAL=0
|
||||
|
||||
run_add() { # $1=spec_key $2=kind $3=title $4=action $5=trigger $6=recur
|
||||
if [ "$MODE" = plan ]; then
|
||||
printf 'add=%s action=%s trigger=%s recur=%s title=%s(plan,沒有寫進去)\n' "$1" "$4" "$5" "$6" "$3"
|
||||
N_ADD=$((N_ADD + 1))
|
||||
return 0
|
||||
fi
|
||||
# 結束碼要在呼叫的下一行就接住。寫成 if 的條件再到後面讀 $?,讀到的是那個 if 整段的碼,
|
||||
# 失敗那一路永遠是 0,於是每一筆失敗都會被回報成 exit=0。
|
||||
"$TASKS_SH" add --kind "$2" --title "$3" --action "$4" --trigger "$5" \
|
||||
--recur "$6" --origin assistant --spec-key "$1" >"$TMPD/add.out" 2>"$TMPD/add.err"
|
||||
_rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
_id=$(sed -n 's/^added=\([0-9A-Fa-f]*\).*/\1/p' "$TMPD/add.out" | head -n1)
|
||||
printf 'add=%s id=%s action=%s trigger=%s recur=%s\n' "$1" "${_id:--}" "$4" "$5" "$6"
|
||||
N_ADD=$((N_ADD + 1))
|
||||
return 0
|
||||
fi
|
||||
cat "$TMPD/add.err" >&2
|
||||
printf 'add_failed=%s exit=%s\n' "$1" "$_rc"
|
||||
RC_PARTIAL=1
|
||||
return 1
|
||||
}
|
||||
|
||||
run_remove() { # $1=spec_key $2=id $3=理由
|
||||
if [ "$MODE" = plan ]; then
|
||||
printf 'remove=%s id=%s reason=%s(plan,沒有刪掉)\n' "$1" "$2" "$3"
|
||||
N_REMOVE=$((N_REMOVE + 1))
|
||||
return 0
|
||||
fi
|
||||
# 一次都不帶 --force:那個旗標留給人。無人值守那一輪碰到 origin=user 就該被擋下。
|
||||
"$TASKS_SH" remove "$2" >"$TMPD/rm.out" 2>"$TMPD/rm.err"
|
||||
_rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
printf 'remove=%s id=%s reason=%s\n' "$1" "$2" "$3"
|
||||
N_REMOVE=$((N_REMOVE + 1))
|
||||
return 0
|
||||
fi
|
||||
cat "$TMPD/rm.err" >&2
|
||||
printf 'remove_failed=%s id=%s exit=%s\n' "$1" "$2" "$_rc"
|
||||
RC_PARTIAL=1
|
||||
return 1
|
||||
}
|
||||
|
||||
# 一、清單上有的,待辦簿裡有沒有。
|
||||
while IFS="$TAB" read -r w_key w_kind w_title w_action w_trigger w_recur; do
|
||||
_hits=$(awk -F"$TAB" -v k="$w_key" '$1==k{n++} END{print n+0}' "$HAVE")
|
||||
if [ "$_hits" -eq 0 ]; then
|
||||
run_add "$w_key" "$w_kind" "$w_title" "$w_action" "$w_trigger" "$w_recur" || true
|
||||
continue
|
||||
fi
|
||||
if [ "$_hits" -gt 1 ]; then
|
||||
# 同一個鍵有兩筆以上,這一支不猜該留哪一筆:兩筆的 last_run 與 fail_count 不同,刪錯
|
||||
# 的那一筆的歷史就沒了。印出來讓人挑。
|
||||
N_DUP=$((N_DUP + 1))
|
||||
printf 'dup=%s count=%s reason=同一個清單鍵有多筆,這一支不動它,請人留一筆\n' "$w_key" "$_hits"
|
||||
awk -F"$TAB" -v k="$w_key" '$1==k{printf " dup_id=%s origin=%s title=%s\n",$2,$3,$5}' "$HAVE"
|
||||
continue
|
||||
fi
|
||||
_id=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $2}' "$HAVE")
|
||||
_origin=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $3}' "$HAVE")
|
||||
if [ "$_origin" != assistant ]; then
|
||||
# 手改過的檔案才會出現這個組合,tasks.sh add 擋得住。一律不動,也不算孤兒。
|
||||
N_SKIP_USER=$((N_SKIP_USER + 1))
|
||||
printf 'skip_user=%s id=%s origin=%s reason=帶清單鍵但不是助理內建,一律不動\n' "$w_key" "$_id" "$_origin"
|
||||
continue
|
||||
fi
|
||||
_h_kind=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $4}' "$HAVE")
|
||||
_h_action=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $6}' "$HAVE")
|
||||
_h_trigger=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $7}' "$HAVE")
|
||||
_h_recur=$(awk -F"$TAB" -v k="$w_key" '$1==k{print $8}' "$HAVE")
|
||||
_diff=''
|
||||
[ "$_h_kind" = "$w_kind" ] || _diff="$_diff kind:$_h_kind → $w_kind"
|
||||
[ "$_h_action" = "$w_action" ] || _diff="$_diff action:$_h_action → $w_action"
|
||||
[ "$_h_trigger" = "$w_trigger" ] || _diff="$_diff trigger:$_h_trigger → $w_trigger"
|
||||
[ "$_h_recur" = "$w_recur" ] || _diff="$_diff recur:$_h_recur → $w_recur"
|
||||
if [ -z "$_diff" ]; then
|
||||
printf 'keep=%s id=%s\n' "$w_key" "$_id"
|
||||
N_KEEP=$((N_KEEP + 1))
|
||||
continue
|
||||
fi
|
||||
N_DRIFT=$((N_DRIFT + 1))
|
||||
if [ "$OPT_REFRESH" -eq 1 ]; then
|
||||
printf 'drift=%s id=%s refresh=1 diff=%s\n' "$w_key" "$_id" "$_diff"
|
||||
if run_remove "$w_key" "$_id" '清單的判定結果換了值,--refresh 要求重新登錄'; then
|
||||
run_add "$w_key" "$w_kind" "$w_title" "$w_action" "$w_trigger" "$w_recur" || true
|
||||
fi
|
||||
else
|
||||
printf 'drift=%s id=%s refresh=0 diff=%s\n' "$w_key" "$_id" "$_diff"
|
||||
printf ' 這一筆沒有改。要照清單換值請帶 --refresh,那一筆會先移除再重新登錄,id 會換,last_run 與 fail_count 會歸零。\n'
|
||||
N_KEEP=$((N_KEEP + 1))
|
||||
fi
|
||||
done <"$WANT"
|
||||
|
||||
# 二、待辦簿裡有、清單上沒有的,就是孤兒。
|
||||
while IFS="$TAB" read -r h_key h_id h_origin h_kind h_title h_action h_trigger h_recur; do
|
||||
[ -n "$h_key" ] || continue
|
||||
if awk -F"$TAB" -v k="$h_key" '$1==k{f=1} END{exit f?0:1}' "$WANT"; then
|
||||
continue
|
||||
fi
|
||||
if [ "$h_origin" != assistant ]; then
|
||||
N_SKIP_USER=$((N_SKIP_USER + 1))
|
||||
printf 'skip_user=%s id=%s origin=%s reason=帶清單鍵但不是助理內建,一律不動\n' "$h_key" "$h_id" "$h_origin"
|
||||
continue
|
||||
fi
|
||||
run_remove "$h_key" "$h_id" '清單上已經沒有這一支,或它改判成不交' || true
|
||||
done <"$HAVE"
|
||||
|
||||
# --- 摘要 ---
|
||||
|
||||
N_HAVE=$(awk 'END{print NR+0}' "$HAVE")
|
||||
printf 'mode=%s spec=%s spec_cols=%s root=%s tasks_sh=%s want=%s have=%s added=%s removed=%s kept=%s drift=%s held=%s bad=%s dup=%s skip_user=%s probe=%s pending=%s probe_bad=%s\n' \
|
||||
"$MODE" "$SPEC" "$SPEC_COLS" "${PROBE_ROOT:--}" "$TASKS_SH" "$N_WANT" "$N_HAVE" "$N_ADD" "$N_REMOVE" "$N_KEEP" \
|
||||
"$N_DRIFT" "$N_HELD" "$N_BAD" "$N_DUP" "$N_SKIP_USER" "$N_PROBE" "$N_PENDING" "$N_PROBE_BAD"
|
||||
|
||||
[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 支是條件式交,這一次沒有種入,逐支印在上面的 held= 那幾行。條件是散文,程式判不了;人確認過某一支的條件成立就帶 --allow-cond 那一支的鍵。"
|
||||
[ "$N_BAD" -gt 0 ] && warn "清單上有 $N_BAD 列對不上,那幾支這一次沒有種入,逐列印在上面的 bad= 那幾行。請回去補清單,不要在這裡補預設值。"
|
||||
[ "$N_PENDING" -gt 0 ] && note "有 $N_PENDING 支切得出唯讀盤點、入口還沒接上,逐支印在上面的 pending= 那幾行,理由是清單上的原文。那幾筆的動作是只提醒,跟本來就只提醒的那幾筆分別在這裡,待辦檔上看不出來。"
|
||||
[ "$N_PROBE_BAD" -gt 0 ] && warn "有 $N_PROBE_BAD 支的 probe 代不進去,逐支印在上面的 probe_bad= 那幾行。那幾筆照樣種入,動作退回只提醒——不種入等於讓清單上一格填錯把一筆帶著歷史的內建項刪掉。請回去補清單,或把缺的 plugin 裝起來。"
|
||||
[ "$N_PROBE" -gt 0 ] && note "有 $N_PROBE 支的動作是一行唯讀盤點指令,逐支印在上面的 probe= 那幾行。holes= 不是減號的那幾筆還留著代入點,執行那一步要先把 {cli} 換成偵測到的 CLI 代號、把 {repo} 換成掃到的存取庫工作目錄,一個目標跑一次;帶著大括號的指令一律不得原樣執行。"
|
||||
[ "$N_DRIFT" -gt 0 ] && [ "$OPT_REFRESH" -eq 0 ] && note "有 $N_DRIFT 筆的判定結果與清單不一樣,這一次照原樣留著。要換值請帶 --refresh,並且知道那一筆的 last_run 與 fail_count 會歸零。"
|
||||
|
||||
[ "$RC_PARTIAL" -eq 0 ] || die 4 "有 add 或 remove 失敗,其餘各筆照做完了。失敗的逐筆印在上面的 add_failed= 與 remove_failed= 那幾行,各自帶了 tasks.sh 的結束碼,照那一支的結束碼表處理。"
|
||||
exit 0
|
||||
+123
-20
@@ -7,24 +7,29 @@
|
||||
# tasks.sh add --kind {check|todo} --title {一句話} --action {技能|腳本|remind}
|
||||
# --trigger {at:...|after:...} --recur {once|every:...|cron:...}
|
||||
# --origin {user|assistant} [--repo {存取庫}] [--due {ISO 時間}]
|
||||
# [--dry-run]
|
||||
# [--spec-key jsc-{domain}:{技能名}] [--dry-run]
|
||||
# tasks.sh done {id} [--last-run {ISO 時間}] [--next-run {ISO 時間}]
|
||||
# tasks.sh fail {id} [--last-run {ISO 時間}]
|
||||
# tasks.sh pause {id}
|
||||
# tasks.sh resume {id}
|
||||
# tasks.sh remove {id} [--force]
|
||||
#
|
||||
# 結束碼:
|
||||
# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了。
|
||||
# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了;
|
||||
# remove 把那一個檔案刪掉了。
|
||||
# pause 對已經是 paused 的那一筆、resume 對已經是 pending 的那一筆,照樣回 0:同一個
|
||||
# 狀態不算轉移,擋它只會讓呼叫端為了「本來就對」的結果去分流
|
||||
# 1 指名的那一筆不存在:done、fail、pause、resume 給的 id 找不到對應檔案
|
||||
# 1 指名的那一筆不存在:done、fail、pause、resume、remove 給的 id 找不到對應檔案
|
||||
# 2 欄位值不合法:必填欄位缺、值不在允許集合、事件名不在固定詞彙表、標題折完是空的、
|
||||
# id 不是十六進位、fail_count 不是非負整數
|
||||
# id 不是十六進位、fail_count 不是非負整數、spec_key 不是 jsc-{domain}:{技能名} 這個形狀,
|
||||
# 或 spec_key 配上 origin=user
|
||||
# 3 不合法的狀態轉移,已擋下。哪些合法見下面「狀態怎麼轉」那張表
|
||||
# 4 這一筆已經有了:add 算出來的完整雜湊撞上一個「建立時間與標題都相同」的既有檔案
|
||||
# 5 檔案系統或雜湊失敗:待辦簿目錄建不起來、檔案寫不進去、這台機器算不出 SHA-1
|
||||
# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、缺 id,或 JSC_HOME 與 HOME 都
|
||||
# 解不出絕對路徑(沒有根目錄可寫,猜一個等於把待辦簿寫到別的地方去)
|
||||
# 7 remove 擋下:那一筆的 origin 是 user,而這一次沒有帶 --force。理由見下面
|
||||
# 「remove 為什麼要擋使用者交辦的那幾筆」
|
||||
#
|
||||
# --- 這一支負責什麼、不負責什麼 ---
|
||||
#
|
||||
@@ -53,11 +58,11 @@
|
||||
#
|
||||
# --- 存放格式:純文字 key=value,一行一欄位 ---
|
||||
#
|
||||
# 一筆固定十四個鍵,順序固定,缺一個都不寫。十四個裡有兩個是格式自己需要的:
|
||||
# 一筆固定十五個鍵,順序固定,缺一個都不寫。十五個裡有兩個是格式自己需要的:
|
||||
# id 檔名,也寫進檔案裡一份。只看檔名的話,檔案被複製或改名之後就對不上內容
|
||||
# created 建立時間,UTC 的 ISO 時間。id 是由它與 title 算出來的,不存它就再也算不回
|
||||
# 同一個 id,也就驗不出檔名對不對,碰撞時也接不下去
|
||||
# 其餘十二個是待辦本身的欄位:
|
||||
# 其餘十三個是待辦本身的欄位:
|
||||
# kind check(定期檢查項)或 todo(交辦事項)。同一本簿、同一組欄位,只用它分
|
||||
# title 一句話講完要做什麼
|
||||
# action 助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手)
|
||||
@@ -70,11 +75,30 @@
|
||||
# next_run 下一次預定執行的時間
|
||||
# fail_count 連續失敗次數
|
||||
# origin user(使用者交辦)或 assistant(助理內建)
|
||||
# spec_key 這一筆是哪一支技能的內建項,寫成 jsc-{domain}:{技能名}。使用者交辦的一律空
|
||||
#
|
||||
# 值是空的照樣把那一行寫出來(例如 repo=)。空值有明確的意思——沒有綁存取庫、沒有截止
|
||||
# 時間、還沒跑過——所以讓每一筆的形狀都一樣,讀的人不必去分「鍵不見了」與「鍵在但是空的」,
|
||||
# 兩眼一比就看得出哪一欄沒填。不認得的鍵一律忽略,往後加欄位不會讓舊檔案讀不進來。
|
||||
#
|
||||
# --- spec_key 為什麼非有不可 ---
|
||||
#
|
||||
# 助理的內建檢查項是照委派清單種進來的,清單改了就要重建:清單新增一支就加一筆,一支改成
|
||||
# 不交或整列被刪掉就把對應那一筆移除。所以重建那一邊一定要能從「一支技能」反查到「待辦簿裡
|
||||
# 屬於它的那一筆」,而其他每一個欄位都反查不了:
|
||||
# id 是「建立時間加標題」的雜湊,跟技能名沒有關係,算不回來也查不過去
|
||||
# title 標題是給人看的一句話。拿它當鍵,等於把標題的措辭變成介面:改一個字,舊那一筆
|
||||
# 就再也認不出來,於是每次重建都刪不掉舊的、又加一筆新的,同一個檢查每輪做兩次
|
||||
# action 交出方式是提醒的那幾筆,action 全部都是 remind 這個同一個字,一支都分不出來;
|
||||
# 而交出方式是觸發的那幾筆,action 是技能名,會跟使用者自己交辦、動作剛好也是
|
||||
# 那一支技能的那一筆撞在一起——撞上就會把使用者交辦的事當成內建項刪掉
|
||||
# origin 只分得出「助理內建」與「使用者交辦」兩群,群裡是哪一支分不出來
|
||||
# 所以身分要有自己的一欄。spec_key 只放身分,不放判定結果:清單裡的 trigger、recur、way
|
||||
# 各自對映到別的欄位,那幾欄會隨清單改動而變,身分不會。
|
||||
# 這一欄與 origin 是兩件事,不可以互相推導:origin=assistant 而 spec_key 是空的,代表這一筆
|
||||
# 是助理自己因為別的理由建的、不受清單管;origin=user 而帶 spec_key 一律擋下(回 2),
|
||||
# 不然下一次重建就會拿清單去刪使用者交辦的事,而規格明寫那幾筆一律不動。
|
||||
#
|
||||
# --- 值裡有等號或換行怎麼辦 ---
|
||||
#
|
||||
# 兩條約定,合起來讓這個格式壞不了,而且不必發明跳脫規則:
|
||||
@@ -127,6 +151,8 @@
|
||||
# paused resume pending paused 只由人設,也只有人解得開
|
||||
# pending resume pending(不算轉移,回 0)
|
||||
# paused pause paused(不算轉移,回 0)
|
||||
# 任何狀態 remove (不存在) 這一筆整個不見。三個狀態都收,理由見下面
|
||||
# 「remove 為什麼要擋使用者交辦的那幾筆」
|
||||
# 被擋下的幾條,各自的理由:
|
||||
# paused + done 停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收
|
||||
# 先 resume,讓「解開」這件事是人做的、看得到的
|
||||
@@ -137,9 +163,9 @@
|
||||
# 與 fail。失敗連續幾次都一樣留在 pending,靠 fail_count 讓人看到,不自動停掉——自動停掉
|
||||
# 等於助理自己決定不做某件事,而且沒有人會發現。
|
||||
#
|
||||
# --- 為什麼是六個操作,不是四個 ---
|
||||
# --- 為什麼是七個操作,不是四個 ---
|
||||
#
|
||||
# 存放層要的是四個:list、add、done、pause。另外兩個是補洞,不是加功能:
|
||||
# 存放層要的是四個:list、add、done、pause。另外三個是補洞,不是加功能:
|
||||
# fail last_run、next_run、fail_count 三個欄位由助理自己維護、不由人填,但四個操作裡
|
||||
# 沒有一個寫得到 fail_count。少了它,fail_count 永遠是 0,監控頁與提醒上的
|
||||
# 「已連續失敗 N 次」就永遠是 0 次,於是一個壞掉的項目每輪重試而沒有人知道——
|
||||
@@ -147,11 +173,30 @@
|
||||
# resume paused 只由人設,也就只有人解得開,沒有別的元件寫得出這個轉移。只給 pause
|
||||
# 不給 resume,pause 就是一道單向門:停掉的那一筆再也回不來,人只能去手改檔案,
|
||||
# 而手改檔案繞過了上面那張轉移表。
|
||||
# remove 規格要求「清單移除或改成不交,待辦簿移除對應那筆,不留孤兒」,而六個操作裡沒有
|
||||
# 一個刪得掉一筆。少了它,重建那一邊只剩兩條路:把孤兒留著,於是助理會去跑一支
|
||||
# 判過不交、甚至已經被刪掉的技能,失敗還不會自動暫停,一路重試;或者自己去 rm
|
||||
# 那個檔案,而這一支的檔頭明寫它是唯一寫得出待辦檔的入口,繞過去之後上面那張
|
||||
# 轉移表與碰撞規則就只約束得到一半的寫入者。所以刪除要走同一個入口。
|
||||
# last_run 與 next_run 不另開操作:done 與 fail 都吃 --last-run 與 --next-run,值由呼叫端
|
||||
# 算好餵進來。這一支不算下一次是什麼時候,算的邏輯在別的地方,兩邊各算一次就會漂移。
|
||||
# 沒有 edit 操作。改欄位值要重新登錄一筆,理由是 id 由 created 與 title 算出來,改掉標題
|
||||
# 之後 id 就對不回去了,留一個算不回來的 id 比多一筆待辦糟。
|
||||
#
|
||||
# --- remove 為什麼要擋使用者交辦的那幾筆 ---
|
||||
#
|
||||
# remove 是這一支唯一真的會弄丟資料的操作,而它的主要呼叫端是無人值守那一輪的清單重建。
|
||||
# 規格對那一輪的規定只有一條:origin=user 的項目一律不動——助理不會因為一支技能改判就把
|
||||
# 使用者交辦的事刪掉。那條規定寫在呼叫端,可是刪除只有這一個入口,所以擋在這裡最省:
|
||||
# 呼叫端每多一個,那條規定就要各自再實作一次,漏掉一個就刪掉一件沒有人同意刪的事,而且是
|
||||
# 在沒有人看的時候刪的,事後也查不出來是誰刪的。
|
||||
# 擋法是回 7 並且什麼都不動,不是靜靜跳過:靜靜跳過會讓呼叫端以為刪掉了,於是它下一步就
|
||||
# 去補一筆新的,最後兩筆並存。
|
||||
# --force 留給人:使用者要刪自己交辦的那一筆是正當的,那一次有人在現場。無人值守那一輪
|
||||
# 一律不帶這個旗標。
|
||||
# 三個狀態都收得下 remove,包含 paused:那不是狀態轉移,是這一筆整個不再存在。擋 paused
|
||||
# 反而會讓一支已經刪掉的技能留下一筆永遠刪不掉的孤兒,只因為有人先按了暫停。
|
||||
#
|
||||
# 環境變數:
|
||||
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜
|
||||
# JSC_HASH_ID 共用 hash 規則那一支的路徑,優先於自動搜尋
|
||||
@@ -203,14 +248,17 @@ warn() { printf '[jsc][助理待辦簿][WARN]:%s\n' "$1" >&2; }
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo 存取庫] [--no-header]
|
||||
usage: tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo 存取庫]
|
||||
[--spec-key jsc-{domain}:{技能名}] [--no-header]
|
||||
tasks.sh add --kind check|todo --title 一句話 --action 技能|腳本|remind
|
||||
--trigger at:...|after:... --recur once|every:...|cron:...
|
||||
--origin user|assistant [--repo 存取庫] [--due ISO 時間] [--dry-run]
|
||||
--origin user|assistant [--repo 存取庫] [--due ISO 時間]
|
||||
[--spec-key jsc-{domain}:{技能名}] [--dry-run]
|
||||
tasks.sh done {id} [--last-run ISO 時間] [--next-run ISO 時間]
|
||||
tasks.sh fail {id} [--last-run ISO 時間]
|
||||
tasks.sh pause {id}
|
||||
tasks.sh resume {id}
|
||||
tasks.sh remove {id} [--force]
|
||||
EOF
|
||||
exit 6
|
||||
}
|
||||
@@ -284,6 +332,15 @@ valid_trigger() { # $1=trigger
|
||||
|
||||
valid_recur() { case "$1" in once|every:?*|cron:?*) return 0 ;; esac; return 1; }
|
||||
|
||||
# spec_key 是重建內建項時的反查鍵,形狀固定 jsc-{domain}:{技能名},兩段都不得為空。
|
||||
# 收得寬一點的話,一個打錯的鍵會變成一筆永遠對不上清單的孤兒:重建那一邊查不到它,
|
||||
# 所以既不會更新也不會移除,而它看起來跟一筆正常的內建項一模一樣。
|
||||
# 兩段都只收小寫英數與連字號:清單的前兩欄本來就長這樣,而冒號是分隔符號,值裡再出現一個
|
||||
# 就切不回兩段。
|
||||
valid_spec_key() {
|
||||
printf '%s' "$1" | LC_ALL=C grep -qE '^jsc-[a-z0-9-]+:[a-z0-9-]+$'
|
||||
}
|
||||
|
||||
valid_count() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; return 0; }
|
||||
|
||||
# id 直接拿去接檔名,所以只收十六進位。帶斜線或點號開頭的值會把讀寫指到待辦簿目錄外面去。
|
||||
@@ -350,7 +407,7 @@ now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
F_id=''; F_created=''; F_kind=''; F_title=''; F_action=''; F_trigger=''
|
||||
F_recur=''; F_repo=''; F_due=''; F_state=''; F_last_run=''; F_next_run=''
|
||||
F_fail_count=''; F_origin=''
|
||||
F_fail_count=''; F_origin=''; F_spec_key=''
|
||||
|
||||
load_record() { # $1=檔案
|
||||
F_id=$(kv_get "$1" id)
|
||||
@@ -367,6 +424,8 @@ load_record() { # $1=檔案
|
||||
F_next_run=$(kv_get "$1" next_run)
|
||||
F_fail_count=$(kv_get "$1" fail_count)
|
||||
F_origin=$(kv_get "$1" origin)
|
||||
# 舊檔案沒有這一鍵,讀回來就是空的,那正好是「不受清單管」的意思,不必補預設值也不必轉檔。
|
||||
F_spec_key=$(kv_get "$1" spec_key)
|
||||
# 手改過的檔案有可能把計數寫成別的東西。當成 0 再往上加,而不是讓算式整支炸掉:這一筆
|
||||
# 的計數本來就已經不可信,讓它從 0 重新開始算得出來,比整支停下更有用。
|
||||
if ! valid_count "$F_fail_count"; then
|
||||
@@ -396,6 +455,7 @@ write_record() { # $1=目標檔案
|
||||
printf 'next_run=%s\n' "$F_next_run"
|
||||
printf 'fail_count=%s\n' "$F_fail_count"
|
||||
printf 'origin=%s\n' "$F_origin"
|
||||
printf 'spec_key=%s\n' "$F_spec_key"
|
||||
} >"$_tmp" 2>/dev/null || { rm -f "$_tmp"; die 5 "待辦簿寫不進去:$_tmp。請確認 $TASKS_DIR 可寫。"; }
|
||||
mv "$_tmp" "$1" 2>/dev/null || { rm -f "$_tmp"; die 5 "待辦簿換不上去:$1。請確認 $TASKS_DIR 可寫。"; }
|
||||
}
|
||||
@@ -418,8 +478,9 @@ resolve_record() { # $1=id;設好 RECORD_FILE
|
||||
|
||||
# 印出改完之後的那一筆,一行講完。改了什麼要看得到,不然呼叫端只拿到一個結束碼。
|
||||
print_record_line() {
|
||||
printf 'id=%s state=%s recur=%s last_run=%s next_run=%s fail_count=%s title=%s\n' \
|
||||
"$F_id" "$F_state" "$F_recur" "${F_last_run:--}" "${F_next_run:--}" "$F_fail_count" "$F_title"
|
||||
printf 'id=%s state=%s recur=%s last_run=%s next_run=%s fail_count=%s spec_key=%s title=%s\n' \
|
||||
"$F_id" "$F_state" "$F_recur" "${F_last_run:--}" "${F_next_run:--}" "$F_fail_count" \
|
||||
"${F_spec_key:--}" "$F_title"
|
||||
}
|
||||
|
||||
# --- list ---
|
||||
@@ -428,20 +489,24 @@ print_record_line() {
|
||||
# 不必再發明引號規則。空欄位就是空的一欄,不填占位符號:填了占位符號,讀的人得再去分
|
||||
# 「真的空」與「占位符號本身」。
|
||||
cmd_list() {
|
||||
_f_kind=''; _f_state=''; _f_repo=''; _header=1
|
||||
_f_kind=''; _f_state=''; _f_repo=''; _f_spec=''; _header=1
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--kind) [ "$#" -ge 2 ] || usage; _f_kind="$2"; shift 2 ;;
|
||||
--state) [ "$#" -ge 2 ] || usage; _f_state="$2"; shift 2 ;;
|
||||
--repo) [ "$#" -ge 2 ] || usage; _f_repo="$2"; shift 2 ;;
|
||||
--spec-key) [ "$#" -ge 2 ] || usage; _f_spec="$2"; shift 2 ;;
|
||||
--no-header) _header=0; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$_f_kind" ] || valid_kind "$_f_kind" || die 2 "--kind 只收 check 或 todo,給的是「$_f_kind」。"
|
||||
[ -z "$_f_state" ] || valid_state "$_f_state" || die 2 "--state 只收 pending、done 或 paused,給的是「$_f_state」。"
|
||||
# 這個篩選是重建內建項時的反查入口,所以形狀擋在這裡:一個打錯的鍵篩出零筆,跟「這一支
|
||||
# 還沒種進來」的結果一模一樣,而後者會讓呼叫端再補一筆。
|
||||
[ -z "$_f_spec" ] || valid_spec_key "$_f_spec" || die 2 "--spec-key「$_f_spec」不是 jsc-{domain}:{技能名} 這個形狀。打錯的鍵篩出零筆,跟「還沒種進來」看起來一樣,而那會讓呼叫端多加一筆。"
|
||||
|
||||
[ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\n'
|
||||
[ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\tspec_key\n'
|
||||
|
||||
# 目錄不存在或零筆都算正常結束:助理還沒收過任何一筆待辦,不是失敗。
|
||||
if [ ! -d "$TASKS_DIR" ]; then
|
||||
@@ -459,16 +524,17 @@ cmd_list() {
|
||||
[ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue
|
||||
[ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue
|
||||
[ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue
|
||||
[ -z "$_f_spec" ] || [ "$_f_spec" = "$F_spec_key" ] || continue
|
||||
case "$F_state" in
|
||||
pending) _rank=0 ;;
|
||||
paused) _rank=1 ;;
|
||||
*) _rank=2 ;;
|
||||
esac
|
||||
_nrk="$F_next_run"; [ -n "$_nrk" ] || _nrk='~'
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$_rank" "$_nrk" "$F_id" \
|
||||
"$F_id" "$F_kind" "$F_state" "$F_title" "$F_action" "$F_trigger" "$F_recur" \
|
||||
"$F_repo" "$F_due" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin"
|
||||
"$F_repo" "$F_due" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" "$F_spec_key"
|
||||
_n=$((_n + 1))
|
||||
done | LC_ALL=C sort -t"$(printf '\t')" -k1,1 -k2,2 -k3,3 | cut -f4-
|
||||
|
||||
@@ -480,6 +546,7 @@ cmd_list() {
|
||||
[ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue
|
||||
[ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue
|
||||
[ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue
|
||||
[ -z "$_f_spec" ] || [ "$_f_spec" = "$F_spec_key" ] || continue
|
||||
_n=$((_n + 1))
|
||||
done
|
||||
printf 'count=%s tasks_dir=%s exists=yes\n' "$_n" "$TASKS_DIR" >&2
|
||||
@@ -490,9 +557,10 @@ cmd_list() {
|
||||
|
||||
cmd_add() {
|
||||
_kind=''; _title=''; _action=''; _trigger=''; _recur=''; _origin=''
|
||||
_repo=''; _due=''; _dry=0
|
||||
_repo=''; _due=''; _spec=''; _dry=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--spec-key) [ "$#" -ge 2 ] || usage; _spec="$2"; shift 2 ;;
|
||||
--kind) [ "$#" -ge 2 ] || usage; _kind="$2"; shift 2 ;;
|
||||
--title) [ "$#" -ge 2 ] || usage; _title="$2"; shift 2 ;;
|
||||
--action) [ "$#" -ge 2 ] || usage; _action="$2"; shift 2 ;;
|
||||
@@ -516,6 +584,7 @@ cmd_add() {
|
||||
_origin=$(fold_and_warn origin "$_origin")
|
||||
_repo=$(fold_and_warn repo "$_repo")
|
||||
_due=$(fold_and_warn due "$_due")
|
||||
_spec=$(fold_and_warn spec_key "$_spec")
|
||||
|
||||
[ -n "$_kind" ] || die 2 '缺 --kind。'
|
||||
valid_kind "$_kind" || die 2 "--kind 只收 check(定期檢查項)或 todo(交辦事項),給的是「$_kind」。"
|
||||
@@ -527,6 +596,10 @@ cmd_add() {
|
||||
valid_recur "$_recur" || die 2 "--recur「$_recur」不合法。只收 once、every:{間隔} 或 cron:{式子}。trigger 與 recur 是兩個獨立欄位,四種組合都成立,不要壓成兩種。"
|
||||
[ -n "$_origin" ] || die 2 '缺 --origin。user(使用者交辦)或 assistant(助理內建)。清單重建時只動 assistant 那幾筆,所以這一欄不能空。'
|
||||
valid_origin "$_origin" || die 2 "--origin 只收 user 或 assistant,給的是「$_origin」。"
|
||||
if [ -n "$_spec" ]; then
|
||||
valid_spec_key "$_spec" || die 2 "--spec-key「$_spec」不是 jsc-{domain}:{技能名} 這個形狀。形狀不對的鍵在重建時對不上清單,於是那一筆既不會更新也不會移除,而它看起來跟一筆正常的內建項一樣。"
|
||||
[ "$_origin" = assistant ] || die 2 "--spec-key 只能配 --origin assistant,這一次的 origin 是「$_origin」。使用者交辦的那一筆帶上清單鍵,下一次重建就會拿清單去刪它,而規格明寫 origin=user 的項目一律不動。要照清單種入請用 assistant;要記下是為了哪一支技能交辦的,請寫進標題。"
|
||||
fi
|
||||
|
||||
_created=$(now_iso)
|
||||
# 雜湊吃的是「建立時間加標題」,中間夾一個定位字元當分隔。標題已經折過,裡面不會有定位
|
||||
@@ -566,13 +639,15 @@ cmd_add() {
|
||||
# 一律生在 pending。生在 done 的那一筆是噪音,生在 paused 是事後才會有的人為決定。
|
||||
F_state=pending
|
||||
F_last_run=''; F_next_run=''; F_fail_count=0; F_origin="$_origin"
|
||||
F_spec_key="$_spec"
|
||||
|
||||
if [ "$_dry" -eq 1 ]; then
|
||||
printf 'dryrun=add id=%s file=%s hash40=%s prefix_len=%s\n' "$_id" "$TASKS_DIR/$_id" "$_full" "$_len"
|
||||
printf -- '--- 會寫進去的內容 ---\n'
|
||||
printf 'id=%s\ncreated=%s\nkind=%s\ntitle=%s\naction=%s\ntrigger=%s\nrecur=%s\nrepo=%s\ndue=%s\nstate=%s\nlast_run=%s\nnext_run=%s\nfail_count=%s\norigin=%s\n' \
|
||||
printf 'id=%s\ncreated=%s\nkind=%s\ntitle=%s\naction=%s\ntrigger=%s\nrecur=%s\nrepo=%s\ndue=%s\nstate=%s\nlast_run=%s\nnext_run=%s\nfail_count=%s\norigin=%s\nspec_key=%s\n' \
|
||||
"$F_id" "$F_created" "$F_kind" "$F_title" "$F_action" "$F_trigger" "$F_recur" \
|
||||
"$F_repo" "$F_due" "$F_state" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin"
|
||||
"$F_repo" "$F_due" "$F_state" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" \
|
||||
"$F_spec_key"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -717,6 +792,33 @@ cmd_resume() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- remove ---
|
||||
|
||||
cmd_remove() {
|
||||
_id="${1:-}"; [ -n "$_id" ] || usage; shift
|
||||
_force=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--force) _force=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
open_target "$_id"
|
||||
# 擋在這裡而不擋在呼叫端,理由見檔頭「remove 為什麼要擋使用者交辦的那幾筆」。
|
||||
if [ "$F_origin" = user ] && [ "$_force" -eq 0 ]; then
|
||||
die 7 "id=$F_id 的 origin 是 user,這是使用者交辦的事,remove 不動它。清單重建那一輪一律不帶 --force:助理不會因為一支技能改判就把使用者交辦的事刪掉。要真的刪請人親自帶 --force 再跑一次。"
|
||||
fi
|
||||
# 先把內容留在畫面上再刪。刪掉之後那一筆的欄位就再也拿不回來了,回報裡只剩一個 id 的話,
|
||||
# 誰都看不出剛剛不見的是什麼。
|
||||
print_record_line
|
||||
rm -f "$RECORD_FILE" 2>/dev/null || die 5 "刪不掉 $RECORD_FILE。請確認 $TASKS_DIR 可寫。"
|
||||
# rm 回 0 不保證檔案真的不在了:唯讀目錄底下的 rm 有可能什麼都沒做。所以回報之前再看一次。
|
||||
[ -f "$RECORD_FILE" ] && die 5 "$RECORD_FILE 還在,這一筆沒有刪掉。請確認 $TASKS_DIR 可寫。"
|
||||
printf 'removed=%s file=%s origin=%s spec_key=%s\n' \
|
||||
"$F_id" "$RECORD_FILE" "$F_origin" "${F_spec_key:--}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 主流程 ---
|
||||
|
||||
RECORD_FILE=''
|
||||
@@ -730,6 +832,7 @@ case "$CMD" in
|
||||
fail) cmd_fail "$@" ;;
|
||||
pause) cmd_pause "$@" ;;
|
||||
resume) cmd_resume "$@" ;;
|
||||
remove) cmd_remove "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
exit $?
|
||||
|
||||
Reference in New Issue
Block a user