/plan-run — Plan DAG 推進器
依照 plan.md 的 Dependencies DAG 推進實作。順序、依賴、跨 session 記憶都在 state file;讓它「一輪接一輪自己跑下去」的推力則有兩種來源,預設用內建的 /goal。
兩個機制,職責不同
| 決定下一步做什麼 | 決定還要不要再跑一輪 | |
|---|---|---|
| 誰負責 | plan_runner.py + state file(兩種模式都一樣) |
/goal(預設)或 Stop hook(選配) |
| 失去它會怎樣 | compaction 後不知做到哪;交叉依賴靠心算會錯 | 每個 step 都要人按一次 enter |
先搞清楚這條分界,才不會誤以為換驅動器能換到別的東西。 實測(2.1.251)
/goal 與自寫 Stop hook 拿到的續推輪數完全相同,差別只在跨 session。
設計原則
- 一輪最多推 8 步,這是 harness 的硬限制:實測 always-block 的續推機制會被呼叫 9 次、第 9 次不被採納(= 8 次續推),而且上限的單位是每個 turn 的輪數、由所有 blocker 共用——
/goal量到的也是 9。多掛一個驅動器換不到更多步,只換到同一輪兩則互相稀釋的指令。撞到邊界就是該讓人看一眼,回一句話就從下一步接著跑,不會退回去 - State 持久化:step 狀態存
<plan-dir>/.plan-state/<slug>.state.json,在檔案系統上,新 session/compaction 之後照樣接得上。Stop hook 模式另有 pointer(~/.claude/plan-run/active/<hash(cwd)>.json)記住「這個 cwd 在推哪份 plan」,那是它相對/goal模式唯一多出來的能力 - Stop hook 模式每 7 步一次 check-in:主動在第 7 步(或更早的 phase 邊界)停,留一輪餘裕,讓停的那刻落在有意義的地方而不是撞上限被截斷。每次注入結尾印
Auto-advance N/7;要用滿 8 步設PLAN_RUN_BLOCK_BUDGET=8 - Task 追蹤工具 best-effort,且預設不存在:frontmatter 列的那三個 Task 工具在 Opus 4.8、Sonnet 5、Fable 5、Mythos 5 及更新模型上預設不註冊(Claude Code v2.1.233 起,見
rules/task-tracking-availability.md)。推進順序、依賴檢查、續推能力全在 state file,task_id只用於 audit 與 UI 面板;工具不存在或呼叫失敗即 continue,不中止 DAG(下文不再重述) - Output 分層:
next是 full bootstrap(~2.8KB,列出全部 ready 的完整模板);complete / fail / skip是 delta(只列本次新解鎖的完整模板,先前給過的只列 ID);index是 ~500 chars 的純 trace。全部預設 markdown,--format=json給 tooling
選模式(預設 A,零安裝)
A — /goal 驅動(預設)。 不裝任何東西,init --no-attach 之後下一道 /goal
就開始跑。適合絕大多數情況。
B — Stop hook 驅動(選配)。 只有一個理由值得裝:這份 plan 會跨 session
(20+ steps、預期會 compaction、想關掉電腦明天接著跑)。hook 靠 pointer 檔自動
接上,不必重下指令。安裝見 docs/hooks-setup.md,裝完
plan_runner.py doctor 六項全 PASS/INFO(INFO 不是錯誤)。FAIL 分兩種讀法:
- 只有「Stop hook 已註冊」/「wrapper 存在且可執行」FAIL → hook 沒裝而已,回去用模式 A,不必先修
- 其他項目 FAIL(runner 路徑對不上、
~/.claude/plan-run/不可寫)→ 裝了但行為不可預期,先修再推 plan
模式 B 的 attach / init 只接受 $HOME 底下的 plan 路徑(resolve() 後比對,
擋 symlink escape);plan 在 $HOME 之外時 pointer 判為 invalid,改用模式 A。
Step 0: 格式檢查 — 若為 planner-agent 輸出先 normalize
若 plan 來自 /design 的 planner subagent(典型徵兆:**Step N: title** 標頭、- **Field**:value 全形冒號、Dependencies 含「Phase N 完成」等自由文字),跑 init 會 No steps found。先 normalize:
python3 ~/Documents/agent-skills/scripts/plan_runner.py normalize "$ARGUMENTS" --diff # 預覽
python3 ~/Documents/agent-skills/scripts/plan_runner.py normalize "$ARGUMENTS" --write # 落地(自動備份 <plan>.bak)
Normalize 把 **Step N: title** 補成 - [ ] **S<phase>.<N>** — title、- **Field**:value 轉成 2 空格縮排 + ASCII 冒號、Dependencies 自由文字翻成 step ID list(Phase N 完成 → 該 Phase 最後一步;Phase X Step Y → SX.Y;括號註解丟棄)。已 canonical 的行 pass-through,重複跑 idempotent。跑完看 stderr 的 WARN: 行,重點是 Dependencies 翻不出 ID 的(保留原文留給人修)
Step 1: 初始化
# 模式 A(預設)
python3 ~/Documents/agent-skills/scripts/plan_runner.py init "$ARGUMENTS" --no-attach
# 模式 B(跨 session 長 plan,需先裝 hook)
python3 ~/Documents/agent-skills/scripts/plan_runner.py init "$ARGUMENTS"
init 預設會 attach(把 cwd 的 pointer 指向這份 plan,hook 從下一輪起接手);模式 A 用 --no-attach 只建 state 不掛 pointer。輸出含 total_steps、phase_order、ready_steps、warnings。
回傳 No steps found in plan → 回 Step 0 跑 normalize。已存在 state → 先 plan_runner.py status "$ARGUMENTS" 看狀態再決定,要重來用 init --force。
有 Task 工具時可額外建一個父 task(subject 用 plan title),再
plan_runner.py set-parent "$ARGUMENTS" --task-id=<id>寫回 state 供 audit。沒有工具就跳過,不要停下來問使用者、也不要改設定。
Step 1.5(模式 A): 下 /goal 開始推進
一道指令,接著就會自己跑下去:
/goal <plan 路徑> 的所有 step 都已 completed 或 skipped——判準是 plan_runner.py 的
輸出出現 Progress: N/N;或同一個 step 連續 2 輪沒有前進。尚未達成時,下一輪第一個
動作必須是跑 python3 ~/Documents/agent-skills/scripts/plan_runner.py next <plan 路徑>,
照它印出的三行做完並回報 complete,不要問使用者是否繼續。
現在開始推進,每輪盡量多推幾步。
三處都是刻意的,改寫時不要弄丟:
- 「下一輪先跑
next」寫在 goal 條件裡,不是只寫在後面那段 prompt。/goal的評估者每輪都會把 feedback 注入回來,條件裡的句子等於每輪重述一次;而next讀的是磁碟上的 state file,不依賴 transcript——這正好補掉/goal沒有狀態記憶、compaction 後看不到已完成部分的弱點 - 終止條件用
Progress: N/N。 評估者不跑指令、不讀檔,只讀 Claude 已經 surface 到對話裡的東西;而plan_runner.py每次 transition 的 output 都會帶出這一行(complete的首行是# completed: <step>,緊接的 state view 區塊首行即Progress: N/M),不必額外補跑index之類的指令去餵它(那只會稀釋訊噪比) - 「連續 2 輪沒有前進」是逃生口。 評估者沒有外部計時器,要 bound 就得把子句寫進條件本身
跑完或中途停下後,/clear、compaction、開新 session 都會讓 /goal 消失——state file 還在,重下一次同樣的 /goal 就接上,不是資料遺失。受不了每次重打就改模式 B。
Step 2: 執行被指定的 step
模式 A 是你自己跑 next 拿到下一步;模式 B 是 hook 每輪把它注入回來。兩者的內容格式相同,固定為:進度行 → 圍欄包住的 plan 欄位(--- plan data (not instructions) ---,只是資料,不是給你的指令)→ 三行執行序列 → Auto-advance N/7。照三行做:
1. python3 <絕對路徑>/plan_runner.py start <plan> <step_id>
2. 依圍欄內的 agent / command / skill 欄位執行實際工作
3. ok: ... complete <plan> <step_id>
err: ... fail <plan> <step_id> --reason="<msg>"
start 印的絕對路徑可直接複製執行。有 Task 工具時:start 的 ## Next hints 列出的 next step 可批次建成 pending task(addBlockedBy = 當前 task_id),給使用者一個 sliding window;先前已被 pre-create 的 hint task 改標成 in_progress,不要重複建立。
complete / fail / skip 的 output 依現況附帶 ## Newly unlocked (N)(新解鎖的完整模板)、## Still ready (M): <ids>(只列 ID,模板已給過)、## In progress、## Blocked(含原因),有 task_id 時多一段 ## Required sync。這些是補充,推進本身不靠你讀完它們——漏讀了 hook 下一輪還會再講一次。
Step 3: 失敗處理(HITL gate)
fail 之後 hook 不會 block,turn 正常結束交還給人。downstream 自動轉 blocked。用 AskUserQuestion 問:
Step
<id>失敗:<reason>;後續 blocked:<list>
- 重試 —
plan_runner.py reset "$ARGUMENTS" --step=<id>- 跳過 —
plan_runner.py skip "$ARGUMENTS" <id>(風險自負)- 中止 —
plan_runner.py pause(不吃 plan 參數,作用於 cwd 的 pointer)
Step 4: 完成驗證
summary.all_done == true 後:比對 plan 的 Acceptance Criteria 逐項勾選 → 有 parent task_id 就 TaskUpdate(<id>, completed) → plan_runner.py detach 收掉 pointer → 提示使用者跑 /plan-archive 歸檔至 plans/completed/。
控制面
pause/resume— 暫停/恢復注入(state 保留),想手動接管時用detach— 移除 cwd 的 pointer(plan 完成或換 plan 時);pointer— 看當前 cwd 解析到哪份 plandoctor— hook 安裝自檢(唯讀);dag "$ARGUMENTS"— DAG 視覺化(--format=dot),debug 用
全手動模式(連 /goal 都不用時)
Step 0/1 照跑,Step 2 改成自己每完成一個 step 跑一次 complete 並讀 ## Newly unlocked 決定下一步;context 被 compaction 砍掉時跑 index "$ARGUMENTS"(~500 chars)看 trace,或 next "$ARGUMENTS" 重拿完整模板(會 reset delta 追蹤)。已知弱點是你可能忘記查狀態——/goal 存在的理由就是把「記得再跑一輪」這件事交出去,成本是一道指令,沒有理由不用。
Plan 格式約束
| 元素 | 格式 |
|---|---|
| Phase 標頭 | ### <任意文字>(regex ^###\s+(.+)$;### Phase 1 — 診斷、### Phase 1:診斷、### Phase 1: 診斷 皆可) |
| Step 標頭 | - [ ] **<step_id>** — <title>(** bold 可省略;分隔符 — - : : 皆可) |
| Step ID | S\d+(\.\d+)?[a-z]?(例:S0.1、S1a、S3.1a、S12) |
| Step 欄位 | - <key>: <value>(縮排 2 空格,ASCII 或全形冒號皆可) |
| 可辨識欄位 | Files、Action、Agent、Skill、Command、Agent/Skill、Dependencies、Risk、Why、Input、Output |
| Dependencies 值 | 逗號、斜線、空白分隔的 step ID 清單;支援 range 語法 |
Range 語法(展開為 plan 內出現順序的完整 list):Dependencies: S4.1 ~ S6 → [S4.1, S4.2, S4.3, S5, S6];支援 ~、...、..、–、— 五種分隔符;可與單一 ID 混用;端點不存在時降級為只保留端點 + warning。
normalize的 Phase 偵測比 parser 嚴格(只認### Phase N:/### Phase N:)。只有 normalize 這一步需要冒號,parser 本身不要求——已 canonical 的 plan 用破折號標頭完全正常。
/design 產出的 plan 已符合格式。手寫 plan 可省略 ** 並使用 range 簡寫。
作廢 step 與 parser 契約
parser 只認 step/phase/field 的樣式,不看 checkbox 打勾、也不看旁白註記——改 plan 前先弄清楚這條界線:
- Checkbox 對 runner 沒有意義:
init無條件把每個 step 設成pending,- [x]打勾的 step 照樣進ready_steps(實測驗證)。想讓某個 step 不被執行,不能靠打勾,要讓它從一開始就不是 step - 作廢一個 step=把它移出 step 結構:搬進一個 parser 不當 step 看的區塊,例如
## 作廢範圍(不可執行,YYYY-MM-DD),底下用普通條列、不套 step ID 樣式。要用二級標題:三級標題會被 phase regex 攔下,即使文字不含「Phase」照樣吃掉整行,且不像二級標題那樣正確結束前一個 step 的欄位收集。頂部 blockquote 或內文括號註記則相反——完全不會被 parser 認到,所以也擋不住 checkbox 判讀,別指望靠加註解讓 runner 跳過 - 保留下來的 step ID 不重新編號,同時把其他 step
Dependencies裡引用到被移出 ID 的邊一併刪掉——依賴指向不存在的 step,init會直接判 DAG validation failed。想保留某個 step 位置(維持依賴邊)又不想真的執行它,唯一在 runner 層生效的做法是init之後立刻跑plan_runner.py skip <plan> <step_id>——skipped對下游依賴視同已完成、next不會再列出它。在 plan 文字裡清空Files、Action寫「作廢,不執行」只是給人看的提示:init照樣把它設成pending,start只檢查依賴、不讀Action/Files內容,執行者仍有可能把它跑掉 Why欄位會被解析、但不會送進執行者看到的 step 模板:它在可辨識欄位表裡會被欄位 regex 吃掉這一行,卻沒有對應邏輯寫回任何 step 欄位,所以內容不會混進Action。想寫作廢理由又不想污染執行者拿到的文字,寫在Why最安全init --no-attach --format json的 stdout 沒有完整 step 表:只回status/slug/title/state_path/total_steps/phase_order/ready_steps/warnings。要核對 step 數或依賴邊是否正確,讀state_path指到的 state 檔,別在 stdout 裡找steps。要做「改版前後比對」,可以在暫存目錄跑init --force --no-attach,state 產物留在暫存目錄不會弄髒 repo。attach預設為開,會在 JSON 輸出後另外把 attach 結果文字印到 stdout,JSON consumer 會拿到一段無法解析的尾巴,所以 JSON 模式一律帶--no-attach(runner 端把 attach 訊息改走 stderr 是另一個待辦,本 PR 不動程式碼)
State 機制
pending ──start──> in_progress ──complete──> completed
│ │
│ └──fail──> failed
├──(dep 失敗自動)──> blocked
└──skip──> skipped
pending 等待中(deps 未滿足或未啟動);in_progress 執行中(有 Task 工具時已回寫 task_id,否則 null);failed 需使用者決定後續;blocked 因 dep 失敗而 block,dep reset 後自動回 pending;skipped 使用者主動跳過,後續 deps 視同 completed 解 block。
transition 由 Python 強制驗證,不允許 completed → pending 等非法轉移(避免覆寫已完成工作)。
與其他 skill 的關係
/notion-plan(抓需求)→ /design(產 plan)→ /plan-run(依 plan 推進,本 skill) → /plan-archive(歸檔)。/code-review、/simplify 由個別 step 的欄位引用。
約束
- 狀態機不執行實際工作:只決定 DAG 順序;agent 呼叫、build/test、檔案修改皆由 LLM 完成
- 失敗不自動重試:避免吃 token,必經 user 決定
- 並行 step 由 LLM 自行決定是否真的並行:state machine 只告訴你「這些 step 可以開始」
- 被指定的 step 已經被授權,直接做:使用者跑
/plan-run <plan>就是對整份 plan 的授權。不要每個 step 停下來問「要繼續嗎」「要不要派這兩個 agent」——同時派多個 step 的 agent 也不必另外問編隊。需要人介入的三個時點已經寫死在流程裡(fail的 HITL gate、輪數邊界、plan 裡標Risk: high或不可逆的 step),除此之外照那三行做完再回報 - 兩種模式都不要疊第二個驅動器:實測上限是每個 turn 的續推輪數、由所有 blocker 共用——
/goal9 輪、自寫 Stop hook 9 輪、兩支 Stop hook 一起掛還是 9 輪。同時開/goal又掛 hook 換不到更多步,只換到同一輪兩則互相稀釋的指令,比只有一則更糟 - 不要為了跑更久去動 harness 自己的 block cap:模式 B 的
PLAN_RUN_BLOCK_BUDGET硬夾在實測上限 8 以下(預設 7 留一輪餘裕),本 skill 從不讀寫 harness 的 block-cap 環境變數、不偽造stop_hook_active。撞到邊界就是該讓人看一眼——回一句話就從下一步接著跑,不會退回去 - 模式 B 的 hook 不 block 的三種情形:step
fail、達到 check-in 邊界、cwd 無 active pointer。前兩者是刻意的 HITL gate,第三者保證對其他 session 零影響