#!/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