Files
assist/tools/seed-tasks.sh
T
jiantw83andClaude Opus 5 28f453176e feat(seed): 種入內建項時改讀委派清單的唯讀盤點指令欄
委派清單補上第十二欄 probe 之後,這一支不再把所有非 invoke 的列一律推成
只提醒:填了指令的四列改成把那一行指令代好代入點寫進動作欄,寫著
pending 的七列照舊只提醒但另外印出來,讓「入口還沒接上」跟「本來就只提醒」
在回報上分得開。

三個判斷刻意寫死:

一、way 含 invoke 的列連看都不看 probe。填了指令會讓整支技能的交出變成
只跑一支腳本,那支技能該寫的頁一頁都不會寫,而且看起來完全正常。

二、probe 代不進去一律退回只提醒並照樣種入。不種入在 apply 那一路等於
移除,於是上游一格填錯就會刪掉一筆帶著 last_run 與 fail_count 的內建項。
金錢符號與波浪號擋在種入這一刻,那兩種寫法在無人值守那一輪解不出來、
也進不了允許清單,會被靜靜擋掉。

三、{cli} 與 {repo} 留在值裡不展開。種入的當下還不知道要代什麼,展開成
多筆會讓同一個 spec_key 有好幾個檔案,一致化整個垮掉。展開由執行那一步
負責,而動作欄裡出現大括號就是還沒代好,一律不得原樣拿去執行。

清單只有十一欄時整份先數一次欄位數,全部照舊推成只提醒、只印一行說明,
行為與加這一欄之前一模一樣。第十三欄以後另接一個收尾變數,免得上游哪天
加一欄就把多出來的值黏進 probe,代入點檢查全過得了關、最後執行的卻是
一行誰都沒寫過的指令。

相依下限刻意不動。清單那一欄晚一步到也照常跑得完,把下限拉到有那一欄的
版本等於逼兩個存放庫排合併順序,而排順序正是這一段程式碼要免掉的事。

三份 manifest 的版號從 0.1.9 升到 0.2.0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:16:13 +08:00

714 lines
40 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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