#!/usr/bin/env sh # tasks.sh — 助理待辦簿的存放與讀寫(供 jsc-assist:assistant 與巡檢那一輪呼叫)。 # # 用法: # tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo {存取庫}] # [--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] # 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} # # 結束碼: # 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了。 # pause 對已經是 paused 的那一筆、resume 對已經是 pending 的那一筆,照樣回 0:同一個 # 狀態不算轉移,擋它只會讓呼叫端為了「本來就對」的結果去分流 # 1 指名的那一筆不存在:done、fail、pause、resume 給的 id 找不到對應檔案 # 2 欄位值不合法:必填欄位缺、值不在允許集合、事件名不在固定詞彙表、標題折完是空的、 # id 不是十六進位、fail_count 不是非負整數 # 3 不合法的狀態轉移,已擋下。哪些合法見下面「狀態怎麼轉」那張表 # 4 這一筆已經有了:add 算出來的完整雜湊撞上一個「建立時間與標題都相同」的既有檔案 # 5 檔案系統或雜湊失敗:待辦簿目錄建不起來、檔案寫不進去、這台機器算不出 SHA-1 # 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、缺 id,或 JSC_HOME 與 HOME 都 # 解不出絕對路徑(沒有根目錄可寫,猜一個等於把待辦簿寫到別的地方去) # # --- 這一支負責什麼、不負責什麼 --- # # 只負責存放與讀寫:把一筆待辦寫成檔案、讀回來、改狀態。到期判定、逾期判定、提醒怎麼送到 # 前景、事件名怎麼對上產生者、欄位不足時怎麼問人,全部不在這一支裡面。 # 所以這一支**留得住**那些欄位,但不對它們做判定: # 只存放,這一支不判定的欄位 # trigger 只驗格式與事件詞彙表,不算「現在到期了沒有」 # recur 只驗格式,不算下一次是什麼時候 # due 只存字串,不比對現在時間,不標逾期 # next_run 只存呼叫端算好的值;這一支自己一次都不算 # last_run done 與 fail 會寫進去,寫的是「這一次執行的時間」,不拿它推算任何事 # fail_count fail 累加、done 歸零,這一支不因為它到某個數字就改 state # 這一支自己判定的只有兩件事:欄位值合不合法(結束碼 2),與狀態轉移合不合法(結束碼 3)。 # 判定邏輯後續才接上來,接的時候不必改這裡的存放格式——欄位已經在檔案裡了。 # # --- 一筆一檔的理由 --- # # 待辦簿存成 $JSC_HOME/assistant/tasks/{id},一筆一檔,理由同 restart-required.d:並行寫入 # 不互相覆寫。五支 CLI 加上排程那一輪有可能同時動待辦簿,整本存成一個檔案的話,兩邊各讀 # 一次整檔、各改自己那一筆、各寫回整檔,後寫的那一次就把前一次的改動整本蓋掉,而且沒有 # 任何訊號。一筆一檔之下,動的是不同的 id 就是動不同的檔案,彼此看不到對方。 # 同一個 id 被同時寫時也不會寫出半份:一律先寫進暫存檔再 mv 過去,mv 在同一個檔案系統上是 # 原子操作,讀的人只會讀到舊的一整份或新的一整份,不會讀到寫到一半的內容。 # 暫存檔名一律以點號開頭,list 的展開跳過點號開頭的檔案:寫到一半的那一份不會被列出來。 # # --- 存放格式:純文字 key=value,一行一欄位 --- # # 一筆固定十四個鍵,順序固定,缺一個都不寫。十四個裡有兩個是格式自己需要的: # id 檔名,也寫進檔案裡一份。只看檔名的話,檔案被複製或改名之後就對不上內容 # created 建立時間,UTC 的 ISO 時間。id 是由它與 title 算出來的,不存它就再也算不回 # 同一個 id,也就驗不出檔名對不對,碰撞時也接不下去 # 其餘十二個是待辦本身的欄位: # kind check(定期檢查項)或 todo(交辦事項)。同一本簿、同一組欄位,只用它分 # title 一句話講完要做什麼 # action 助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手) # trigger 第一次什麼時候到期。at:{ISO 時間}、at:now,或 after:{事件名} # recur 做完之後還要不要再排。once、every:{間隔},或 cron:{式子} # repo 這一筆綁哪一個存取庫。機器層級的檢查項留空 # due 截止時間。留空就是沒有截止時間,那是合法狀態,不是缺欄位 # state pending、done 或 paused # last_run 上一次執行的時間 # next_run 下一次預定執行的時間 # fail_count 連續失敗次數 # origin user(使用者交辦)或 assistant(助理內建) # # 值是空的照樣把那一行寫出來(例如 repo=)。空值有明確的意思——沒有綁存取庫、沒有截止 # 時間、還沒跑過——所以讓每一筆的形狀都一樣,讀的人不必去分「鍵不見了」與「鍵在但是空的」, # 兩眼一比就看得出哪一欄沒填。不認得的鍵一律忽略,往後加欄位不會讓舊檔案讀不進來。 # # --- 值裡有等號或換行怎麼辦 --- # # 兩條約定,合起來讓這個格式壞不了,而且不必發明跳脫規則: # 一、讀的時候只在**第一個等號**斷開。鍵是固定的十四個詞,一個都不含等號,所以第一個 # 等號一定是分隔符號,後面全部算值。title=a=b 讀回來就是 a=b,寫的時候不必動它。 # 二、寫的時候把值**折成一行**:換行、歸位、定位字元各折成一個空白,其餘控制字元刪掉, # 連續空白併成一個,前後空白去掉。折過就在 stderr 記一行,不靜靜改人家的值。 # 第二條選折行而不選跳脫,理由是讀的人不只這一支腳本:技能本文與巡檢那一輪都會直接把檔案 # 當 key=value 讀。跳脫規則要每一個讀的人各自實作一次,漏掉一個,那個人就把 \n 兩個字原樣 # 印進報告或監控頁,看起來還很像正常內容。不跳脫就不用還原,每一個讀的人只要在第一個等號 # 斷開,拿到的就是存進去的那個值。 # 代價是值裡真的換行會被折掉。這本簿的每一個欄位本來就都是一行——標題是一句話、時間是一個 # 時間戳、動作是一個技能名或腳本——折行沒有丟掉屬於這本簿的資訊。真的需要長篇內容的東西 # 該寫成 wiki 頁再用 action 指過去,不是塞進標題。 # 另外,命令替換本來就會吃掉結尾的換行,所以值傳到這裡之前結尾的換行已經不見了。這件事 # 講在這裡,是為了讓人不要以為折行有保住結尾的換行。 # # --- id 為什麼取前 8 碼,碰撞怎麼辦 --- # # 共用 hash 規則(jsc-gitea 的 tools/hash-id)是完整四十碼大寫、不截短。這一支照樣先算出 # 完整四十碼,只在取檔名的時候取前 8 碼,理由是兩者的用途不同: # 四十碼那個規則管的是 wiki 頁名。頁名要在整個站台裡唯一,而且頁名一撞就是兩台機器的 # 紀錄互相覆寫,看不出來,所以那裡不准截短。 # 這裡的 id 是本機檔名,還要被人念出來、打進 done 與 pause、印在狀態表與提醒文字裡。 # 四十碼的十六進位字串塞進表格沒有人讀得完,也沒有人打得對,於是人會改用「第三筆」這種 # 說法指定要關哪一筆,那才是真正會關錯的地方。 # 兩者不必一致,因為 id 在 wiki 上只是某一列裡的一個值,不是頁名,撞不到頁名的唯一性。 # 前 8 碼是完整四十碼的前綴,不是另一套算法:要驗一個 id 對不對,就拿 created 與 title # 重算四十碼,再比前綴,隨時驗得回來。 # 碰撞這樣處理: # 前 8 碼撞上既有檔案,而那個檔案的 created 或 title 跟這一筆不同,就是真的前綴碰撞。 # 把前綴每次多取兩碼(8、10、12……一路到 40)再試,取到不撞為止。多取的還是同一個 # 雜湊的前綴,所以前一段那個「重算就驗得回來」的性質不變;不在後面補 -2 這種序號, # 補序號的 id 就再也算不回來了。 # created 與 title 都相同的話,那不是碰撞,那是同一筆被登錄兩次——同一秒、同一個標題就是 # 同一件事。這時候一律不寫,回 4 並把既有的 id 印出來。定期檢查項會因為清單重建而重跑 # 登錄,靜靜多寫一筆的話,同一個檢查每輪就會做兩次。 # 同一秒登錄兩筆不同標題的待辦不會撞:雜湊吃的是「建立時間加標題」,標題不同雜湊就不同。 # # --- 狀態怎麼轉 --- # # 只有三個狀態,合法的轉移就這幾條,其餘一律回 3 擋下: # 起點 操作 終點 說明 # (不存在) add pending 一律生在 pending。生在 done 的那一筆是 # 噪音;生在 paused 是事後才會有的人為決定 # pending done done(recur 是 once) 一次性做完就收掉 # pending done pending(recur 會重複) 重複的做完要重新排,所以留在 pending # pending fail pending 失敗只累加 fail_count,state 不動 # pending pause paused 只有人會下這個操作 # paused resume pending paused 只由人設,也只有人解得開 # pending resume pending(不算轉移,回 0) # paused pause paused(不算轉移,回 0) # 被擋下的幾條,各自的理由: # paused + done 停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收 # 先 resume,讓「解開」這件事是人做的、看得到的 # paused + fail 同理。助理沒有跑它,就不可能是它失敗 # done + 任何 一次性且已經收掉的那一筆不再有下一次。再 done 一次會改寫 last_run, # 再 pause 一次會讓它看起來在等人解開 # 助理自己絕不寫 paused:能寫出 paused 的只有 pause 這一個操作,而巡檢那一輪只會叫 done # 與 fail。失敗連續幾次都一樣留在 pending,靠 fail_count 讓人看到,不自動停掉——自動停掉 # 等於助理自己決定不做某件事,而且沒有人會發現。 # # --- 為什麼是六個操作,不是四個 --- # # 存放層要的是四個:list、add、done、pause。另外兩個是補洞,不是加功能: # fail last_run、next_run、fail_count 三個欄位由助理自己維護、不由人填,但四個操作裡 # 沒有一個寫得到 fail_count。少了它,fail_count 永遠是 0,監控頁與提醒上的 # 「已連續失敗 N 次」就永遠是 0 次,於是一個壞掉的項目每輪重試而沒有人知道—— # 那正是這個欄位要防的事。所以失敗這條路要有自己的入口。 # resume paused 只由人設,也就只有人解得開,沒有別的元件寫得出這個轉移。只給 pause # 不給 resume,pause 就是一道單向門:停掉的那一筆再也回不來,人只能去手改檔案, # 而手改檔案繞過了上面那張轉移表。 # last_run 與 next_run 不另開操作:done 與 fail 都吃 --last-run 與 --next-run,值由呼叫端 # 算好餵進來。這一支不算下一次是什麼時候,算的邏輯在別的地方,兩邊各算一次就會漂移。 # 沒有 edit 操作。改欄位值要重新登錄一筆,理由是 id 由 created 與 title 算出來,改掉標題 # 之後 id 就對不回去了,留一個算不回來的 id 比多一筆待辦糟。 # # 環境變數: # JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 # JSC_HASH_ID 共用 hash 規則那一支的路徑,優先於自動搜尋 set -u JSC_HOME_RAW="${JSC_HOME:-}" if [ -z "$JSC_HOME_RAW" ]; then # JSC_HOME 沒設就退回 ~/.jsc,與這個 domain 的其他腳本同一個預設值:兩邊退回的位置不同, # 待辦簿就會躲在一個沒有人去讀的目錄裡,而每一支都自認為讀對了。 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" JSC_HOME_FALLBACK=1 else JSC_HOME_FALLBACK=0 fi # 根目錄一定要是絕對路徑。相對路徑在排程那一輪等於指向 cron 的工作目錄,那一輪會把待辦簿 # 寫到別的地方去,而下一輪從正確的地方讀,看到的是零筆。 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" STATE_DIR="$JSC_HOME/assistant" TASKS_DIR="$STATE_DIR/tasks" CURRENT="$JSC_HOME/current" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) SCRIPT_DIR="${SCRIPT_DIR:-.}" die() { # $1=結束碼 $2=訊息 printf '[jsc][助理待辦簿][ERR]:%s\n' "$2" >&2 exit "$1" } note() { printf '[jsc][助理待辦簿]:%s\n' "$1" >&2; } 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] tasks.sh add --kind check|todo --title 一句話 --action 技能|腳本|remind --trigger at:...|after:... --recur once|every:...|cron:... --origin user|assistant [--repo 存取庫] [--due ISO 時間] [--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} EOF exit 6 } # 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。判準與處置同這個 domain 的其他 # 腳本:只警告、照跑。從工作樹直接跑是開發時的正當用法,中止會把那條路擋掉;真正的失敗 # 會發生在權限閘門那裡,閘門只放行 current 那一組確切路徑。 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 [ "$JSC_HOME_FALLBACK" -eq 1 ] && note "JSC_HOME 沒有設定,這一次用 $JSC_HOME。待辦簿的位置會隨 HOME 變動,排程那一輪與現在這個殼的 HOME 不一定相同:要固定就把 JSC_HOME 設起來。" # --- 值的讀與寫 --- # 取一個鍵的值。只在第一個等號斷開,所以值裡的等號原樣讀回來。 # 先把歸位字元刪掉:這一支寫出來的檔案沒有歸位字元,但手改過的檔案可能有,留著會混進值裡。 # 同一個鍵重複出現時只認第一次,不把兩行併起來——併起來會生出一個誰都沒寫過的值。 kv_get() { # $1=檔案 $2=鍵 tr -d '\r' <"$1" 2>/dev/null | sed -n "s/^$2=//p" | head -n1 } # 把值折成一行。換行、歸位、定位字元折成空白,其餘控制字元刪掉,連續空白併一個,前後去掉。 fold_value() { # $1=原值 printf '%s' "$1" \ | tr '\n\r\t' ' ' \ | tr -d '\000-\037' \ | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[[:space:]][[:space:]]*/ /g' } # 折過就講一聲。靜靜改掉人家給的值,下一次他從報告裡看到的東西跟他給的不一樣,而且找不到 # 是誰改的。 fold_and_warn() { # $1=欄位名 $2=原值;印出折好的值 _f=$(fold_value "$2") if [ "$_f" != "$2" ]; then warn "$1 的值裡有換行、定位字元或多餘空白,已經折成一行:「$_f」。這本簿的每一個欄位都是一行,長篇內容請另外寫成 wiki 頁再用 action 指過去。" fi printf '%s' "$_f" } # --- 欄位值的合法性 --- valid_kind() { case "$1" in check|todo) return 0 ;; esac; return 1; } valid_state() { case "$1" in pending|done|paused) return 0 ;; esac; return 1; } valid_origin() { case "$1" in user|assistant) return 0 ;; esac; return 1; } # 事件名只認固定詞彙表。理由:填一個永遠不會發生的事件名,那筆待辦就永遠不到期,而且從 # 檔案上看不出壞在哪——它看起來跟一筆正常的待辦一模一樣。所以寫進去的那一刻就擋。 # 四個不帶參數,三個一定要帶參數;帶不帶寫錯一律當不合法,不自己補。 valid_event() { # $1=after: 後面那一整段 case "$1" in worklog-written|hook-error|session-start|session-end) return 0 ;; wp-merged:?*|stage-entered:?*|analyze-completed:?*) return 0 ;; esac return 1 } valid_trigger() { # $1=trigger case "$1" in at:?*) return 0 ;; after:?*) valid_event "${1#after:}" && return 0; return 1 ;; esac return 1 } valid_recur() { case "$1" in once|every:?*|cron:?*) return 0 ;; esac; return 1; } valid_count() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; return 0; } # id 直接拿去接檔名,所以只收十六進位。帶斜線或點號開頭的值會把讀寫指到待辦簿目錄外面去。 # 長度收 8 到 40:8 是預設前綴,碰撞時會加長,加長後最多就是完整四十碼。 # 用 grep 而不用 case 的否定字集,是因為那種寫法在註解掃描裡會被認成別的東西。 valid_id() { _n=${#1} [ "$_n" -ge 8 ] && [ "$_n" -le 40 ] || return 1 printf '%s' "$1" | LC_ALL=C grep -qE '^[0-9A-Fa-f]{8,40}$' } # --- 雜湊 --- # 找共用 hash 規則那一支。搜尋順序比照這個 domain 其他腳本找 jsc-hooks 的做法:先環境變數 # 覆寫,再 current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的快取版面。 # current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部自己去挑另一個 # 版本,同一輪就會跑到混版的工具,那種不一致查起來沒有線索。 hash_id_sh() { if [ -n "${JSC_HASH_ID:-}" ] && [ -f "$JSC_HASH_ID" ]; then printf '%s\n' "$JSC_HASH_ID"; return 0 fi if [ -f "$CURRENT/jsc-gitea/tools/hash-id" ]; then printf '%s\n' "$CURRENT/jsc-gitea/tools/hash-id"; return 0 fi _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" for _c in "$_root/../gitea/tools/hash-id" "$_root/../jsc-gitea/tools/hash-id"; do [ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/hash-id\n' "$(pwd)"); return 0; } done _c=$(ls "$_root"/../../jsc-gitea/*/tools/hash-id \ "$_root"/../../gitea/*/tools/hash-id \ "$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/hash-id 2>/dev/null \ | sort | tail -n1) [ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } return 1 } # 算出完整四十碼大寫。優先叫共用那一支;那一支找不到才自己算。 # 備援不能拿掉:jsc-gitea 不一定裝在這台機器上,缺了它就一筆待辦都登錄不了,而登錄不了的 # 那一刻使用者就在現場,錯過了就再也問不到。備援算的是同一條規則——完整四十碼、a-f 轉大寫、 # 不截短——所以兩條路算出來的值相同,只有「取前綴當檔名」這一步是本機的事。 hash40() { # $1=要算的字串 _h=$(hash_id_sh 2>/dev/null) || _h='' if [ -n "$_h" ]; then _out=$(printf '%s' "$1" | sh "$_h" 2>/dev/null) || _out='' case "$_out" in [0-9A-F]*) printf '%s' "$_out"; return 0 ;; esac warn "共用 hash 規則那一支($_h)算不出雜湊,這一次改用本機的 SHA-1。兩者是同一條規則,值相同。" else warn '找不到共用 hash 規則那一支(jsc-gitea 的 tools/hash-id),這一次改用本機的 SHA-1。兩者是同一條規則,值相同。' fi if command -v sha1sum >/dev/null 2>&1; then printf '%s' "$1" | sha1sum | awk '{print $1}' | tr a-f A-F; return 0 fi if command -v shasum >/dev/null 2>&1; then printf '%s' "$1" | shasum -a 1 | awk '{print $1}' | tr a-f A-F; return 0 fi return 1 } 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='' load_record() { # $1=檔案 F_id=$(kv_get "$1" id) F_created=$(kv_get "$1" created) F_kind=$(kv_get "$1" kind) F_title=$(kv_get "$1" title) F_action=$(kv_get "$1" action) F_trigger=$(kv_get "$1" trigger) F_recur=$(kv_get "$1" recur) F_repo=$(kv_get "$1" repo) F_due=$(kv_get "$1" due) F_state=$(kv_get "$1" state) F_last_run=$(kv_get "$1" last_run) F_next_run=$(kv_get "$1" next_run) F_fail_count=$(kv_get "$1" fail_count) F_origin=$(kv_get "$1" origin) # 手改過的檔案有可能把計數寫成別的東西。當成 0 再往上加,而不是讓算式整支炸掉:這一筆 # 的計數本來就已經不可信,讓它從 0 重新開始算得出來,比整支停下更有用。 if ! valid_count "$F_fail_count"; then [ -n "$F_fail_count" ] && warn "$1 的 fail_count 是「$F_fail_count」,不是非負整數,這一次當成 0。" F_fail_count=0 fi [ -n "$F_state" ] || F_state=pending } # 整份寫進暫存檔再 mv 過去。mv 在同一個檔案系統上是原子操作,所以讀的人只會讀到舊的一整份 # 或新的一整份。暫存檔名帶行程號,兩個同時在跑的行程不會互搶同一個暫存檔;名字以點號開頭, # list 的展開跳過它,寫到一半的那一份不會被列出來。 write_record() { # $1=目標檔案 _tmp="$TASKS_DIR/.tmp.$$" { printf 'id=%s\n' "$F_id" printf 'created=%s\n' "$F_created" printf 'kind=%s\n' "$F_kind" printf 'title=%s\n' "$F_title" printf 'action=%s\n' "$F_action" printf 'trigger=%s\n' "$F_trigger" printf 'recur=%s\n' "$F_recur" printf 'repo=%s\n' "$F_repo" printf 'due=%s\n' "$F_due" printf 'state=%s\n' "$F_state" printf 'last_run=%s\n' "$F_last_run" printf 'next_run=%s\n' "$F_next_run" printf 'fail_count=%s\n' "$F_fail_count" printf 'origin=%s\n' "$F_origin" } >"$_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 可寫。"; } } ensure_dir() { [ -d "$TASKS_DIR" ] && return 0 mkdir -p "$TASKS_DIR" 2>/dev/null || die 5 "建不出待辦簿目錄:$TASKS_DIR。" } # 指名那一筆的檔案路徑。 # 這一段刻意不寫成「印出路徑、由呼叫端用命令替換接」的函式:那樣它是在子行程裡跑,裡面的 # die 只結束子行程,外面照樣往下走,於是「id 不合法」會被回報成「找不到那一筆」,結束碼 # 也從 2 變成 1。呼叫端拿到的碼與真正的原因不一樣,比沒有分碼更糟。 resolve_record() { # $1=id;設好 RECORD_FILE valid_id "$1" || die 2 "id「$1」不是 8 到 40 碼的十六進位。id 直接拿去接檔名,帶別的字元會把讀寫指到待辦簿目錄外面去。" _up=$(printf '%s' "$1" | tr a-f A-F) RECORD_FILE="$TASKS_DIR/$_up" [ -f "$RECORD_FILE" ] || die 1 "待辦簿裡找不到 id=$_up。請先跑 list 看現有的幾筆;id 是十六進位,大小寫都收。" } # 印出改完之後的那一筆,一行講完。改了什麼要看得到,不然呼叫端只拿到一個結束碼。 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" } # --- list --- # 輸出是定位字元分隔。值一律折過,裡面不會有定位字元也不會有換行,所以定位字元分隔讀得準, # 不必再發明引號規則。空欄位就是空的一欄,不填占位符號:填了占位符號,讀的人得再去分 # 「真的空」與「占位符號本身」。 cmd_list() { _f_kind=''; _f_state=''; _f_repo=''; _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 ;; --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」。" [ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\n' # 目錄不存在或零筆都算正常結束:助理還沒收過任何一筆待辦,不是失敗。 if [ ! -d "$TASKS_DIR" ]; then printf 'count=0 tasks_dir=%s exists=no\n' "$TASKS_DIR" >&2 return 0 fi _n=0 # 排序鍵:state 分組(pending、paused、done),再 next_run,再 id。pending 排在前面是 # 因為那是要看的東西;沒有 next_run 的排在同組最後,鍵補 ~ —— LC_ALL=C 之下它排在 # 數字與字母後面,所以「還沒排下一次」的那幾筆不會擠在有時間的前面。 for _fp in "$TASKS_DIR"/*; do [ -f "$_fp" ] || continue load_record "$_fp" [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || 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' \ "$_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" _n=$((_n + 1)) done | LC_ALL=C sort -t"$(printf '\t')" -k1,1 -k2,2 -k3,3 | cut -f4- # 上面那一段在管線的子行程裡跑,_n 加不回來,所以計數另外數一次。 _n=0 for _fp in "$TASKS_DIR"/*; do [ -f "$_fp" ] || continue load_record "$_fp" [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue _n=$((_n + 1)) done printf 'count=%s tasks_dir=%s exists=yes\n' "$_n" "$TASKS_DIR" >&2 return 0 } # --- add --- cmd_add() { _kind=''; _title=''; _action=''; _trigger=''; _recur=''; _origin='' _repo=''; _due=''; _dry=0 while [ "$#" -gt 0 ]; do case "$1" in --kind) [ "$#" -ge 2 ] || usage; _kind="$2"; shift 2 ;; --title) [ "$#" -ge 2 ] || usage; _title="$2"; shift 2 ;; --action) [ "$#" -ge 2 ] || usage; _action="$2"; shift 2 ;; --trigger) [ "$#" -ge 2 ] || usage; _trigger="$2"; shift 2 ;; --recur) [ "$#" -ge 2 ] || usage; _recur="$2"; shift 2 ;; --origin) [ "$#" -ge 2 ] || usage; _origin="$2"; shift 2 ;; --repo) [ "$#" -ge 2 ] || usage; _repo="$2"; shift 2 ;; --due) [ "$#" -ge 2 ] || usage; _due="$2"; shift 2 ;; --dry-run) _dry=1; shift ;; *) usage ;; esac done # 必填欄位一個都不補預設值。猜出來的時間點與週期會讓助理拿一個沒有人同意過的時程去跑, # 半筆待辦比沒有待辦更糟。缺了就回 2,讓呼叫端當著使用者的面把它問回來。 _kind=$(fold_and_warn kind "$_kind") _title=$(fold_and_warn title "$_title") _action=$(fold_and_warn action "$_action") _trigger=$(fold_and_warn trigger "$_trigger") _recur=$(fold_and_warn recur "$_recur") _origin=$(fold_and_warn origin "$_origin") _repo=$(fold_and_warn repo "$_repo") _due=$(fold_and_warn due "$_due") [ -n "$_kind" ] || die 2 '缺 --kind。' valid_kind "$_kind" || die 2 "--kind 只收 check(定期檢查項)或 todo(交辦事項),給的是「$_kind」。" [ -n "$_title" ] || die 2 '缺 --title,或標題折完之後是空的。標題是一句話講完要做什麼,空標題在狀態表上認不出是哪一筆。' [ -n "$_action" ] || die 2 '缺 --action。助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手)。' [ -n "$_trigger" ] || die 2 '缺 --trigger。第一次什麼時候到期:at:{ISO 時間}、at:now,或 after:{事件名}。' valid_trigger "$_trigger" || die 2 "--trigger「$_trigger」不合法。只收 at:{ISO 時間}、at:now,或 after:{事件名};事件名只認這七個:worklog-written、hook-error、session-start、session-end、wp-merged:{工作包代號}、stage-entered:{階段}、analyze-completed:{HASH}。填一個不在表上的事件名,那筆待辦永遠不到期,而且從檔案上看不出壞在哪。" [ -n "$_recur" ] || die 2 '缺 --recur。做完之後還要不要再排:once、every:{間隔},或 cron:{式子}。' 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」。" _created=$(now_iso) # 雜湊吃的是「建立時間加標題」,中間夾一個定位字元當分隔。標題已經折過,裡面不會有定位 # 字元,所以這個分隔切得乾淨:不夾分隔的話,時間結尾與標題開頭黏起來會有兩組不同的輸入 # 算出同一個雜湊。 _full=$(hash40 "$_created$(printf '\t')$_title") \ || die 5 '這台機器既沒有 sha1sum 也沒有 shasum,算不出 id。' case "$_full" in [0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F]*) ;; *) die 5 "算出來的雜湊不像完整四十碼大寫十六進位:「$_full」。" ;; esac [ "$_dry" -eq 1 ] || ensure_dir # 前綴每次多取兩碼,直到不撞。撞上的那一筆 created 與 title 都相同時不是碰撞,是同一筆 # 被登錄兩次,回 4 並印出既有的 id。 _len=8 _id='' while [ "$_len" -le 40 ]; do _cand=$(printf '%s' "$_full" | cut -c1-"$_len") if [ ! -f "$TASKS_DIR/$_cand" ]; then _id="$_cand"; break fi _old_created=$(kv_get "$TASKS_DIR/$_cand" created) _old_title=$(kv_get "$TASKS_DIR/$_cand" title) if [ "$_old_created" = "$_created" ] && [ "$_old_title" = "$_title" ]; then die 4 "這一筆已經有了:id=$_cand,建立時間與標題都相同。同一秒、同一個標題就是同一件事,不再寫一份——定期檢查項會因為清單重建而重跑登錄,多寫一筆就會讓同一個檢查每輪做兩次。要真的另立一筆,請改標題。" fi warn "id 前 $_len 碼撞到既有的 $_cand(那一筆的標題不同),前綴加長兩碼再試。" _len=$((_len + 2)) done [ -n "$_id" ] || die 5 "完整四十碼都撞上既有檔案,而那一筆的建立時間或標題又不同。這在實務上不會發生,請人工檢查 $TASKS_DIR。" F_id="$_id"; F_created="$_created"; F_kind="$_kind"; F_title="$_title" F_action="$_action"; F_trigger="$_trigger"; F_recur="$_recur"; F_repo="$_repo" F_due="$_due" # 一律生在 pending。生在 done 的那一筆是噪音,生在 paused 是事後才會有的人為決定。 F_state=pending F_last_run=''; F_next_run=''; F_fail_count=0; F_origin="$_origin" 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' \ "$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" return 0 fi write_record "$TASKS_DIR/$_id" printf 'added=%s file=%s hash40=%s prefix_len=%s\n' "$_id" "$TASKS_DIR/$_id" "$_full" "$_len" print_record_line # next_run 這一支不算。重複的那幾筆要有下一次的時間,由算到期的那一邊算好之後用 done # 的 --next-run 餵回來;這裡先留空,留空的意思是「還沒排下一次」,不是「不再排」。 case "$F_recur" in once) ;; *) note "這一筆是重複的(recur=$F_recur),next_run 現在留空。下一次什麼時候跑由算到期的那一邊算,算好之後用 done 的 --next-run 寫進來;這一支不算。" ;; esac return 0 } # --- done、fail、pause、resume --- # 四個操作共用的取件與轉移擋人。轉移表見檔頭「狀態怎麼轉」。 open_target() { # $1=id resolve_record "$1" load_record "$RECORD_FILE" } cmd_done() { _id="${1:-}"; [ -n "$_id" ] || usage; shift _last=''; _next='' while [ "$#" -gt 0 ]; do case "$1" in --last-run) [ "$#" -ge 2 ] || usage; _last="$2"; shift 2 ;; --next-run) [ "$#" -ge 2 ] || usage; _next="$2"; shift 2 ;; *) usage ;; esac done open_target "$_id" case "$F_state" in pending) ;; paused) die 3 "id=$F_id 現在是 paused,不收 done。停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收先跑 resume $F_id,讓「解開」這件事是人做的、看得到的。" ;; done) die 3 "id=$F_id 已經是 done,不收第二次 done。一次性且已經收掉的那一筆不再有下一次,再 done 一次只會改寫 last_run,把一個沒發生過的執行記進去。" ;; *) die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; esac F_last_run=$(fold_and_warn last_run "${_last:-$(now_iso)}") [ -z "$_next" ] || F_next_run=$(fold_and_warn next_run "$_next") # 做完就把連續失敗次數歸零。留著的話,一個修好之後又跑成功的項目會一直掛著「已連續失敗 # N 次」,那個 N 就不再是「連續」。 F_fail_count=0 case "$F_recur" in once) F_state=done ;; *) # 重複的那幾筆做完留在 pending,等下一次。 F_state=pending [ -n "$_next" ] || note "這一筆是重複的(recur=$F_recur),這一次沒有帶 --next-run,next_run 維持「${F_next_run:-空}」。下一次什麼時候跑由算到期的那一邊算,這一支不算。" ;; esac write_record "$RECORD_FILE" printf 'done=%s\n' "$F_id" print_record_line return 0 } cmd_fail() { _id="${1:-}"; [ -n "$_id" ] || usage; shift _last='' while [ "$#" -gt 0 ]; do case "$1" in --last-run) [ "$#" -ge 2 ] || usage; _last="$2"; shift 2 ;; *) usage ;; esac done open_target "$_id" case "$F_state" in pending) ;; paused) die 3 "id=$F_id 現在是 paused,不收 fail。助理沒有在跑它,就不可能是它失敗。" ;; done) die 3 "id=$F_id 已經是 done,不收 fail。收掉的那一筆不再執行,記一次失敗上去會讓它看起來還在重試。" ;; *) die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; esac F_last_run=$(fold_and_warn last_run "${_last:-$(now_iso)}") F_fail_count=$((F_fail_count + 1)) # state 一律留 pending,下一輪照重試。助理不自動轉 paused:自動停掉等於助理自己決定不做 # 某件事,而且沒有人會發現。要讓人看到的是 fail_count,監控頁與提醒都要標「已連續失敗 # N 次」。 F_state=pending write_record "$RECORD_FILE" printf 'failed=%s fail_count=%s\n' "$F_id" "$F_fail_count" print_record_line note "id=$F_id 已連續失敗 $F_fail_count 次,state 留在 pending,下一輪照重試。這一筆要標進監控頁與提醒,不然一個壞掉的項目會每輪重試而沒有人知道。" return 0 } cmd_pause() { _id="${1:-}"; [ -n "$_id" ] || usage; shift [ "$#" -eq 0 ] || usage open_target "$_id" case "$F_state" in paused) # 同一個狀態不算轉移。擋它只會讓呼叫端為了「本來就對」的結果去分流。 printf 'paused=%s unchanged=1\n' "$F_id" print_record_line return 0 ;; pending) ;; done) die 3 "id=$F_id 已經是 done,不收 pause。收掉的那一筆沒有下一次可以停,停了只會讓它看起來在等人解開。" ;; *) die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; esac F_state=paused write_record "$RECORD_FILE" printf 'paused=%s\n' "$F_id" print_record_line note "paused 只由人設,助理自己不設也解不開:巡檢那一輪只會叫 done 與 fail,寫不出 paused。要讓這一筆再跑就跑 resume $F_id。" return 0 } cmd_resume() { _id="${1:-}"; [ -n "$_id" ] || usage; shift [ "$#" -eq 0 ] || usage open_target "$_id" case "$F_state" in pending) printf 'resumed=%s unchanged=1\n' "$F_id" print_record_line return 0 ;; paused) ;; done) die 3 "id=$F_id 已經是 done,不收 resume。它不是被停掉的,是做完收掉的;要再做一次請重新登錄一筆。" ;; *) die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; esac F_state=pending # fail_count 不歸零。它記的是真的發生過的失敗,解開一筆待辦沒有把那些失敗變成沒發生; # 歸零會把「已連續失敗 N 次」這句提醒抹掉,而那筆待辦一恢復就會照樣再失敗一次。 write_record "$RECORD_FILE" printf 'resumed=%s\n' "$F_id" print_record_line [ "$F_fail_count" -gt 0 ] && note "id=$F_id 的 fail_count 是 $F_fail_count,解開之後刻意留著:那幾次失敗真的發生過,歸零會把「已連續失敗 N 次」這句提醒抹掉。要歸零請等它跑成功一次,done 會自己歸零。" return 0 } # --- 主流程 --- RECORD_FILE='' CMD="${1:-}" [ -n "$CMD" ] || usage shift case "$CMD" in list) cmd_list "$@" ;; add) cmd_add "$@" ;; done) cmd_done "$@" ;; fail) cmd_fail "$@" ;; pause) cmd_pause "$@" ;; resume) cmd_resume "$@" ;; *) usage ;; esac exit $?