Imported from Xertox1234/OCRecipes (
.claude/skills/todo/SKILL.md). Install upstream withnpx skills add Xertox1234/OCRecipes --skill todo. Copyright stays with the author.
You are running the todo orchestrator. This workflow cleans up prior runs, triages the backlog, plans execution order, dispatches executor agents, and reports results. Never skip phases.
Phase 0 — Cleanup Sweep
Before anything else, clear leftovers from previous /todo runs. This phase always runs and never aborts the workflow — if a step fails (e.g. gh is unauthenticated), report it and continue to Phase 1.
-
Force-remove leftover executor worktrees. Executor worktrees are created locked, so
git worktree prunealone silently skips them and they accumulate forever. Force-remove every one. Use the non---porcelainform and expand a leading~manually — some environments proxygit(e.g. this project'srtkhook, see CLAUDE.md/RTK.md) and rewrite--porcelain's output into a condensed, non-standard single-line format with~-shorthand paths, which breaks a^worktreeanchor silently (zero matches, no error) and whichread-into-a-variable does not tilde-expand at use time:git worktree list | awk '/\.claude\/worktrees\/agent-/ {sub(/ +[0-9a-f]{4,40} +\[[^]]*\].*$/, ""); print}' | while read -r wt; do wt="${wt/#\~/$HOME}" git worktree unlock "$wt" 2>/dev/null git worktree remove --force "$wt" 2>/dev/null && echo "removed worktree: $wt" done git worktree prune -
Delete stale remote branches. Every
/todorun pushes atodo/<slug>branch for its PR; nothing deletes it after the PR merges, so they pile up onorigin. Delete every remote branch whose PRs are allMERGED— but never one with an open PR, never one whose PR was closed WITHOUT merging (that is a rejection signal, not cleanup — see below), and nevermainor the current branch:git fetch --prune --quiet CURRENT=$(git branch --show-current) # Start clean: sweep outputs must never survive from a previous run — a skipped sweep # (gh failure / limit cap) would otherwise leave stale lists for later steps to trust. rm -f /tmp/todo-open-prs.txt /tmp/todo-delete-branches.txt /tmp/todo-closed-unmerged-branches.txt \ /tmp/todo-local-branches.txt /tmp/todo-delete-local-branches.txt /tmp/todo-local-closed-unmerged-branches.txt \ /tmp/todo-local-no-pr-branches.txt /tmp/todo-delete-skipped.txt /tmp/todo-scheduler-state.json # ONE fetch of every PR; the open/merged/closed views below derive from it. gh returns # newest-first, so a truncated fetch silently drops the OLDEST PRs — exactly the ones # the sweep needs. If the returned count EQUALS the limit, treat the sweep as # unreliable: keep the open-PR list (best available data for Phase 2) but skip branch # deletion this run and note the skip in the Phase 5 summary. gh pr list --state all --limit 1000 --json headRefName,state > /tmp/todo-all-prs.json \ || { rm -f /tmp/todo-all-prs.json; echo "gh pr list failed — step SKIPPED"; } if [ -s /tmp/todo-all-prs.json ]; then jq -r '.[] | select(.state=="OPEN") | .headRefName' /tmp/todo-all-prs.json | sort -u > /tmp/todo-open-prs.txt if [ "$(jq 'length' /tmp/todo-all-prs.json)" -eq 1000 ]; then echo "PR fetch hit the --limit cap — sweep unreliable; skipping branch deletion this run" else jq -r '.[] | select(.state=="MERGED") | .headRefName' /tmp/todo-all-prs.json | sort -u > /tmp/todo-merged-prs.txt jq -r '.[] | select(.state=="CLOSED") | .headRefName' /tmp/todo-all-prs.json | sort -u > /tmp/todo-closed-prs.txt # Shared gate: given a sorted branch-list file, print only entries whose PRs are ALL # merged (no open PR, no closed-unmerged PR) — used for both remote and local refs # below so the two sweeps can't drift out of sync with each other. merged_only() { comm -12 /tmp/todo-merged-prs.txt "$1" | comm -23 - /tmp/todo-open-prs.txt | comm -23 - /tmp/todo-closed-prs.txt } git branch -r --format='%(refname:short)' | sed 's#^origin/##' \ | grep -vxE "HEAD|main|${CURRENT:-main}" | sort -u > /tmp/todo-remote-branches.txt # Delete only all-MERGED branches: ≥1 merged PR, no open PR, no closed-unmerged PR. merged_only /tmp/todo-remote-branches.txt > /tmp/todo-delete-branches.txt # Closed WITHOUT merging and no open PR = a rejected implementation — never sweep # it silently; carry this list to the Phase 5 attention section. comm -12 /tmp/todo-closed-prs.txt /tmp/todo-remote-branches.txt \ | comm -23 - /tmp/todo-open-prs.txt > /tmp/todo-closed-unmerged-branches.txt if [ -s /tmp/todo-delete-branches.txt ]; then # The snapshot only SELECTS candidates. A FRESH per-branch check gates each # actual delete — the snapshot can go stale mid-run (the PR #520 incident # class: a branch merged-in-snapshot can have a new open PR by delete time). : > /tmp/todo-delete-skipped.txt while read -r b; do if bash scripts/verify-branch-merged.sh "$b"; then git push origin --delete "$b" else echo "$b" >> /tmp/todo-delete-skipped.txt fi done < /tmp/todo-delete-branches.txt git fetch --prune --quiet fi # Local branch cleanup — mirror the remote sweep for local todo/* refs. `git worktree # remove` (step 1) leaves the branch ref, and guard-eligible PRs auto-merge with # --delete-branch, so the local todo/<slug> is usually the SOLE survivor and is NOT in # /tmp/todo-remote-branches.txt. Join LOCAL branches against MERGED PRs (PRs outlive # their deleted branches). Same gate as remote: all-MERGED only, never # current/open/closed-unmerged. git branch --list 'todo/*' --format='%(refname:short)' \ | grep -vxF "${CURRENT:-x}" | sort -u > /tmp/todo-local-branches.txt merged_only /tmp/todo-local-branches.txt > /tmp/todo-delete-local-branches.txt if [ -s /tmp/todo-delete-local-branches.txt ]; then while read -r b; do bash scripts/verify-branch-merged.sh "$b" \ || { echo "$b" >> /tmp/todo-delete-skipped.txt; continue; } out=$(git branch -D "$b" 2>&1) && echo "deleted local branch: $b" \ || { echo "$out" | grep -qE "checked out at|used by worktree at" || echo "WARNING: could not delete local branch $b: $out"; } done < /tmp/todo-delete-local-branches.txt fi # Local closed-unmerged (rejection signal) — mirrors the remote check above. Never # auto-delete it, but DO surface it in Phase 5, or a local-only rejection signal # (its remote branch may already be gone) silently vanishes with no trace at all. comm -12 /tmp/todo-closed-prs.txt /tmp/todo-local-branches.txt \ | comm -23 - /tmp/todo-open-prs.txt > /tmp/todo-local-closed-unmerged-branches.txt # No PR at all, in ANY state — a genuine orphan: the executor renamed its worktree # branch to todo/<slug> at Step 10, but the process crashed or the push/`gh pr # create` call failed before any PR ever existed. Never auto-delete or auto-push # it — it might be genuine in-flight work — only detect and surface in Phase 5. sort -u /tmp/todo-merged-prs.txt /tmp/todo-open-prs.txt /tmp/todo-closed-prs.txt \ > /tmp/todo-all-pr-branches.txt comm -23 /tmp/todo-local-branches.txt /tmp/todo-all-pr-branches.txt \ > /tmp/todo-local-no-pr-branches.txt fi fiIf the
ghcall fails (unavailable, unauthenticated, network), the block above deletes the temp file and this step is SKIPPED — no stale/tmplists survive for later phases to trust, no branch deletion happens, and Phase 2 fetches its own open-PR list (worktree cleanup in step 1 still ran). Continue. -
Report what was cleaned: count of worktrees removed, the list of remote branches deleted, and the list of local
todo/*branches deleted (or "nothing to clean"). If anyWARNING:line was printed (agit branch -Dthat failed for a reason other than the branch being checked out elsewhere), carry it verbatim. If/tmp/todo-closed-unmerged-branches.txt,/tmp/todo-local-closed-unmerged-branches.txt,/tmp/todo-local-no-pr-branches.txt, or/tmp/todo-delete-skipped.txt(branches whose FRESH merge-state check failed at delete time) is non-empty, or the sweep was skipped (gh failure or the--limitcap), carry that in orchestrator state — Phase 5 surfaces all of it. -
Sync the local default branch (
main). PRs from prior runs land via auto-merge or the user's review (possibly from another session), so those todos may already be archived onorigin/mainwhile the local checkout still shows them at the old path — and the backlog would otherwise re-pick an already-merged todo. Fast-forward localmain. Like the rest of Phase 0 this never aborts the run, and it is ff-only so it never disturbs parallel work:git fetch origin main --quiet || true CUR=$(git branch --show-current) if [ "$CUR" = "main" ] || [ "$CUR" = "master" ]; then if [ -z "$(git status --porcelain)" ]; then git pull --ff-only -q origin "$CUR" 2>/dev/null && echo "synced local $CUR with origin" \ || echo "local $CUR not fast-forwardable — skipping (pull manually)" else echo "skipped main sync — working tree dirty" fi else # Not on the default branch: fast-forward the local main ref without touching the # current branch/working tree. Refuses (harmlessly) if it would not be a fast-forward. git fetch origin main:main 2>/dev/null && echo "fast-forwarded local main ref" \ || echo "local main not fast-forwardable — pull manually when on main" fiThen proceed to Phase 1.
Phase 1 — Baseline
Establish a green baseline before touching any code.
-
Run all three commands:
npm run test:run npm run check:types npm run lint -
Record the test count (e.g., "1327 tests passed"), the type-check result (e.g., "0 errors"), and the lint result (e.g., "0 warnings, 0 errors").
-
Capture the base branch before creating any worktrees:
git branch --show-currentIf the output is empty (detached HEAD state), fall back to:
git rev-parse --abbrev-ref HEADIf that also returns
HEAD, stop immediately and report "cannot determine base branch — HEAD is detached. Please check out a named branch before running /todo." Do not proceed to Phase 2.Store the branch name as
BASE_BRANCH(e.g.,feat/nutrition-inline-drawersormain). Pass it to every executor spawn in Phase 4 via theBase branch:line in the prompt.Confirm
BASE_BRANCHout loud whenever it is notmain. Basing a batch on a feature branch is a deliberate, supported choice — but the capture above takes whatever branch you happen to be standing on, and it is silently wrong when/todois invoked from a linked worktree, where the current branch is that worktree's unrelated feature work. Every executor forks fromBASE_BRANCH, so a wrong value puts that unrelated feature diff into every PR in the batch. Check which checkout you are in:git rev-parse --git-common-dirUse the bare form here. It prints
.gitat the top level of the main checkout and an absolute path in a linked worktree, which is the signal needed. The--path-format=absolutevariant used in the next step is always absolute and can never distinguish the two — the two calls answer different questions and must not be merged.If
BASE_BRANCHis notmain, or the command above printed anything other than.git, stop and report before dispatching. (It also prints an absolute path from a subdirectory of the main checkout, so this can ask one extra time — confirm either way; the cost of a redundant question is nothing against a batch of PRs carrying someone else's diff.)BASE_BRANCH = <branch> (worktree: <linked | main checkout>) Every executor will fork from this branch. Confirm or give a different base.Wait for an explicit answer. Do not proceed to Phase 2 on silence.
Then capture the main checkout's absolute path using
git rev-parse --git-common-dir(worktree-aware —pwdwould be wrong if/todois invoked from inside another worktree):MAIN_CHECKOUT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"Important: Shell state does not persist between tool calls — each Bash call runs in a fresh shell. Record the literal output of this command (e.g.
/Users/yourname/projects/OCRecipes) and substitute it wherever<MAIN_CHECKOUT>appears in the spawn prompts below and in the executor's instructions. Do not re-run this command in later phases; use the saved value.Store as
MAIN_CHECKOUT(e.g.,/Users/williamtower/projects/OCRecipes). Pass it to every executor spawn in Phase 4 via theMain checkout:line in the prompt. Executors use it to resolve paths outside their worktree when a gitignored artifact must survive worktree teardown (same gitignore/worktree pitfall the/auditskill fixed). Solution files (docs/solutions/...) are git-tracked: executors write them worktree-relative and commit them on the todo branch (see/codify). -
Verify executor agent: Confirm the file
.claude/agents/todo-executor.mdexists by running:test -f .claude/agents/todo-executor.md && echo "found" || echo "missing"If missing, stop immediately and report "Cannot find .claude/agents/todo-executor.md — the executor agent is required. Please restore it before running /todo."
-
If ANY command fails or any check above returns missing, stop immediately. Report the failure to the user and exit — do not proceed to Phase 2. The codebase must be green before batch processing begins.
Phase 2 — Triage
Build the work queue from the todos/ backlog.
-
Read all
.mdfiles intodos/— excludeREADME.md,TEMPLATE.md, anything insidetodos/archive/, and anything insidetodos/deployment/(deployment/scaling work is parked until launch is financially viable). -
Parse each file's YAML frontmatter. Extract:
title,status,priority,created,labels,blocked_until,blocked_reason,human_led. -
Filter to actionable todos: status is
backlogorplanned. Skip any todo with statusin-progress,blocked,review, ordone.Stuck todos: If any file has
status: in-progress, it was left mid-run by a crashed executor and is being skipped. To re-queue it, manually edit its frontmatter tostatus: backlogand re-run/todo.Awaiting merge (skip). A completed todo's archive move rides its unmerged PR branch, so the local
todos/*.mdstill saysbackloguntil that PR merges (auto-merge or the user's review) — triage must not re-pick it. Reuse this run's open-PR list from Phase 0 step 2 (/tmp/todo-open-prs.txt); only if Phase 0's gh step was skipped, fetch it now (gh pr list --state open --limit 1000 --json headRefName --jq '.[].headRefName' | sort -u > /tmp/todo-open-prs.txt— never trust a/tmpfile left by a previous run; Phase 0 rewrites or deletes it every run precisely so this step can trust it). Skip any actionable todo whose slug (filename minus.md) exactly matches an opentodo/<branch-slug>branch — executors are required to use the exact filename slug as the branch name, so exact match is the only join. A match means the todo is already implemented and its PR is awaiting merge; re-dispatching would re-implement it and collide with its own open PR. Carry the skipped set (with each todo's PR branch) in orchestrator state and list it in the Phase 5 summary under "Awaiting merge". If the list cannot be fetched at all, continue without this check (the executor's Step 2 remote-branch probe and Step 10 push-collision triage are the downstream backstops).
3a. Gate check (date / human-led) — status-independent, no override in this path. Run once for the whole backlog, in scan mode (no argument):
```bash
scripts/todo-gate-check.sh
```
This reads `blocked_until`/`human_led` directly from every `todos/*.md` file — **independent of what `status` currently says**, so a todo whose `status` was edited away from `blocked` (by hand, by a prior session, or by an agent) is still caught. Exit 0 = no gated todos found, nothing to do. Exit 1 = at least one gated todo; stdout has one TSV line per gated file (`<path>\t<blocked_until-or-dash>\t<reason>`). Exit 2 = the check itself failed — **treat identically to exit 1** (fail-closed: an unverifiable actionable list is not a clean one).
Remove every todo whose path appears in the output from the actionable list — matching on the exact `todos/<filename>.md` path form `scripts/todo-gate-check.sh` emits (relative to the repo root, same form step 1 already reads files as), **regardless of `/goal` directive wording** ("drive every actionable todo," "clear the backlog," etc. is never a per-todo override — see `todos/README.md` → "Date & Human-Led Gates"). Carry the removed set (filename, `blocked_until`, `blocked_reason`/reason text) in orchestrator state for Phase 5's "Gated" heading.
**There is no override in this path — full stop.** `/todo`'s batch run never legitimately targets one specific gated todo by name; if the user wants to run a gated todo anyway, they invoke `/todo-fast <path>` directly, which has its own interactive-confirmation gate (`.claude/skills/todo-fast/SKILL.md` Phase 0). Never edit `blocked_until`, `human_led`, or `status` on a gated todo to make it pass this check — that edit is the exact bypass the 2026-07-16 incident exploited.
4. Quality check. Catching authoring problems here is much cheaper than spawning a researcher + executor only to have them fail on an incoherent spec. For each actionable todo, scan the body and record any flag that fires:
- empty-AC — the Acceptance Criteria section is missing or contains no
- [ ]checkbox lines. - thin-IN — the Implementation Notes section body (text between its heading and the next heading) is shorter than 50 characters after trimming whitespace.
- no-files — the body contains no file reference matching the patterns Phase 3 uses for extraction (
path/to/file.ts,path/to/file.ts:123-145, or backtick-quoted paths).
Record each flagged todo with the comma-joined list of triggered flags. Multiple flags are possible.
-
Sort the actionable list:
- Priority descending:
critical>high>medium>low - Within the same priority, oldest
createddate first (FIFO)
- Priority descending:
-
Display the work queue as a markdown table with a
Qualitycolumn showingOKor the comma-joined flag list:# Priority Title Quality Labels Created 1 high ... OK ... ... 2 medium ... thin-IN ... ... Default behavior: only todos with
Quality = OKproceed to Phase 3 and beyond. Flagged todos are dropped from this run's queue and surfaced again in the Phase 5 summary under "Skipped — quality flags" so the user can re-author them and re-run. The dropped set must be carried in orchestrator state for Phase 5.If every actionable todo is flagged, report "All actionable todos failed quality checks — re-author and re-run." and exit. If the queue is empty (no actionable todos at all), report "No actionable todos found" and exit.
Phase 3 — Dependency Analysis
Determine which todos can safely run in parallel and which must run sequentially.
-
Extract file paths from each todo's full body (Implementation Notes, Acceptance Criteria, any other sections). Match these patterns:
- Bare paths:
path/to/file.ts - Paths with line ranges:
path/to/file.ts:123-145 - Backtick-quoted paths:
`path/to/file.ts`
- Bare paths:
-
Build a file-overlap map: two todos are "dependent" if they share any mentioned file path (ignoring line ranges — file-level granularity).
-
Check inter-todo dependencies. Also parse each todo's Dependencies section. If a todo lists another todo filename as a dependency and that file still exists in
todos/(not yet archived onmain), do not schedule the dependent in this run at all — even if the dependency is dispatched and completes earlier in this run's queue, its archive lands onmainonly when its PR merges (auto-merge or the user's review — either way, not yet), so a same-run dispatch of the dependent is guaranteed to reportblocked(wasted worktree + researcher + executor). The skip reason depends on the dependency's actual state — never claim a PR merge will unblock it unless that PR exists:- Dependency has an open
todo/*PR (check the Phase 0/2 open-PR list): skip with reasongated on <dependency>'s PR (<branch>) landing→ Phase 5 "Gated on a pending PR". - Dependency has no PR (quality-dropped, previously failed, or never attempted): skip with reason
gated on <dependency> — not implemented yet→ Phase 5 "Gated on a dependency (not yet implemented)". The unblock is re-authoring or a future run of the dependency, not a merge. - Dependency is scheduled in THIS run: defer the wording — at Phase 5 time use the dependency's actual outcome (PR opened → first bullet; failed/blocked → second bullet).
- Dependency has an open
-
Todos that mention NO specific files must run sequentially. Unknown scope means they could potentially conflict with anything.
-
DB-serial todos must run alone. A todo whose body mentions
shared/schema.ts,migrations/,drizzle, ordb:pushperforms DDL against the shared dev database — mark it[db-serial]in the plan display in addition to itsmust-run-alonetag (step 6), so Phase 4 knows to add the lock-acquisition dispatch block to it specifically (scope-unknownmust-run-alonetodos don't need that block). The executor-side advisory lock (scripts/pg-lab/db-serial-lock.sh, dispatched below) is the enforcement backstop for the runs this planning rule cannot see (a second orchestrator, a manual session); it does not replace this rule (feedback_todo_parallel_shared_dev_db). -
Tag each remaining todo
independentormust-run-alone. DB-serial todos (step 5) and scope-unknown todos (step 4) aremust-run-alone— each may only run by itself, with nothing else in flight at the same time. Every other todo (disjoint file sets, each mentioning at least one file) isindependentand can run alongside otherindependenttodos. -
Order the tagged set into a single priority queue, keeping the priority/date sort from Phase 2. This queue has no execution-boundary structure — it's the fill order Phase 4's rolling scheduler draws from, not a set of batches. The concurrency cap is 4, enforced by the scheduler per completion (Phase 4), not by chunking here.
-
Display the execution plan as the ordered queue:
Queue (priority order): 1. [high, independent] Extract suggestion generation service 2. [high, independent] Storage facade re-exports 3. [medium, must-run-alone] Remix screen reader announcements — scope unknown 4. [medium, must-run-alone, db-serial] Add recipe_tags junction table 5. [low, independent] Fix useCollapsible height test type error 6. [low, independent] Extract toDateString utilityindependentitems fill free slots as they open, in this order; amust-run-aloneitem only starts once nothing else is running, and blocks every other item — including anothermust-run-aloneitem — from starting while it runs (Phase 4). In the queue above: items 1 and 2 start immediately, filling 2 of 4 slots. Item 3 can't start alongside them, so items 5 and 6 fill the remaining 2 slots instead of sitting idle — the exact idle-slot waste this design exists to remove. Item 4 waits behind item 3 even after slots free up, since twomust-run-aloneitems can't run concurrently with each other either. -
Advisor review of the schedule (gated). Call the
advisortool before dispatching Phase 4 only when the queue contains 2 or moreindependentitems — those are the only ones the scheduler can ever run at the same time. Skip it when fewer than 2 independent items exist; nothing in that queue can overlap in time. (If the advisor tool is not available in the session, skip this step.)The advisor sees this orchestrator's full transcript — the todo bodies, the file-overlap map from steps 1–2, and the queue — and reviews exactly one question: could any two
independentitems the scheduler might run concurrently actually conflict? Could two of them touch the same file (a shared import, a barrel file, or a type the overlap analysis missed) — the rolling scheduler may co-run any pair ofindependentitems, not just ones that happen to sit near each other in the display, so a missed overlap between any two is live risk. And separately: is anything taggedindependentthat should bemust-run-alone? Two executors editing one file in separate worktrees produce conflicting branches and stacked PRs — expensive and hard to unwind once agents are live.Nothing has executed yet, so revising is cheap. If the advisor flags a risky pairing, retag one of the conflicting todos
must-run-alone(or drop it from this run), re-display the revised queue, then proceed. Weigh the advice seriously, but it is advisory: if a flag is clearly wrong (the files genuinely do not overlap), note why and continue.
Phase 4 — Execute
Run the queue with a rolling dispatcher: keep up to 4 executors in flight, refilling a slot the instant it frees rather than waiting for every currently-running executor to finish. scripts/todo-scheduler.ts makes the "what's eligible right now" decision — see its header comment for the two invariants it enforces (a must-run-alone item excludes everything else while it runs; an independent item needs a free slot and no file overlap with anything active).
Executor dispatch
Every executor is a todo-executor agent spawned in an isolated worktree via the Agent tool with these parameters:
Agent({
description: "Execute todo: <todo title>",
subagent_type: "general-purpose",
model: "sonnet",
isolation: "worktree",
prompt: "You are a todo executor agent. Follow the instructions in .claude/agents/todo-executor.md exactly.\n\nYour todo file: todos/<filename>.md\nBase branch: <BASE_BRANCH>\nMain checkout: <MAIN_CHECKOUT>\n\nFirst action, before any other step: run `scripts/pg-lab/session-coord.sh register --kind todo-executor` (registers this session's kind in the coordination registry; silently no-ops if Postgres is down).\n\nExecute all steps in order and report the result."
})
Substitute the actual branch name you recorded in Phase 1 (e.g., feat/nutrition-inline-drawers) for <BASE_BRANCH> and the actual main checkout path (e.g., /Users/williamtower/projects/OCRecipes) for <MAIN_CHECKOUT>. Never pass the literal text <BASE_BRANCH> or <MAIN_CHECKOUT>.
DB-serial todos only (the subset of must-run-alone items marked so in Phase 3 step 5 — not the scope-unknown ones): add this block to the dispatch prompt —
Before your first
db:push/DDL step: resolve the session pid withWATCH_PID=$(bash -c '. scripts/pg-lab/lib/ps-walk.sh && resolve_claude_pid'), then runWATCH_INTERVAL_SECS=10 scripts/pg-lab/db-serial-lock.sh acquire --watch-pid "$WATCH_PID"with a Bash timeout of 600000. Exit 0 → proceed. Exit 2 → retry the acquire ONCE; a second exit 2 means another session is live on the shared dev DB — mark this todoblockedwith an ACTION NEEDED reason quoting the holder identity the command printed, and stop. If the printed holder identity contains this session's own id, the lock was abandoned by an earlier executor in this run — recovery is a manualscripts/pg-lab/db-serial-lock.sh release, not waiting for another session. Exit 3 → proceed unlocked and note "db-serial lock unavailable (watch-pid unresolvable)" in the PR body. If the Bash tool call itself times out (600s) with no exit code, treat it exactly like exit 2. If the acquire exited 0, runscripts/pg-lab/db-serial-lock.sh releaseon completion — success OR failure. Never run release after exit 2 or exit 3: you do not hold the lock, and release force-frees whoever does.
How dispatch actually resumes — read this before the loop below
Dispatched agents run in the background. Launching them does not block your current turn, and there is no in-turn way to wait for one to finish — do not poll, sleep, or reach for a scheduling tool to "wait." Instead: after dispatching, your turn simply ends (respond to the user, or just stop). Later, when ONE dispatched agent completes, the harness re-invokes you as a new turn carrying that agent's result — never when the whole running set finishes, only ever one at a time. The loop below is written around that: each numbered pass through step 2 is a separate turn, not a step inside one long turn.
Because the run now spans many turns instead of one barrier per batch, don't rely on conversational memory for the queue/running/results state — a long run can have its earlier context summarized away. Persist it instead: write /tmp/todo-scheduler-state.json ({"queue": [...], "running": [...], "results": [...]}) every time any of the three changes, and re-read it at the start of each turn below rather than trusting what you remember — this applies as much to each completed todo's recorded outcome (needed for Phase 5's summary table) as it does to the queue and running set. This is the same pattern Phase 0 already uses for cross-step state (/tmp/todo-*.txt), just as JSON.
The rolling loop
-
Seed dispatch. Write the full queue (from Phase 3) and an empty running set to
/tmp/todo-scheduler-state.json, then call the scheduler with that same state:npx tsx scripts/todo-scheduler.ts <<'EOF' {"cap": 4, "running": [], "queue": [{"id": "<todo slug>", "files": ["<path>", "..."], "tag": "<independent-or-must-run-alone>"}, "..."]} EOFThe script prints the JSON array of queue items eligible to dispatch now. Launch one
Agent()call per item — all in the same message — using the dispatch block above, move each dispatched item fromqueueintorunningin/tmp/todo-scheduler-state.json, and save it. Then end your turn. -
On each wake-up (a task-notification carrying one completed executor's result — see "How dispatch actually resumes" above): re-read
/tmp/todo-scheduler-state.jsonfirst, don't assume your own memory of it is still accurate. Then: a. Record its result exactly as described under "Recording results" below, appending it toresultsin the state file. b. Remove that todo's worktree immediately, using theWORKTREEpath from its report (every report shape carries one — seetodo-executor.mdStep 11):git worktree unlock "<WORKTREE>" 2>/dev/null git worktree remove --force "<WORKTREE>" 2>/dev/nullDo this for every outcome — success, failed, blocked, and skipped all get their worktree torn down the same way, immediately, rather than waiting for Phase 5. c. Drop it from
runningin the state file. d. Call the scheduler again with the updatedrunningand remainingqueuefrom the state file (same shape as step 1); dispatch whatever it returns, move each newly-dispatched item fromqueueintorunning, save the state file, then end your turn again. -
Repeat step 2 — each occurrence is a separate turn — until the state file shows both
queueandrunningempty, then proceed to Phase 5. Do not delete/tmp/todo-scheduler-state.jsonyet — Phase 5 readsresultsfrom it first and deletes it as part of its own cleanup.
Recording results
Each executor reports one of: success, failed, blocked, skipped. Every skipped/blocked report carries a REASON_CODE (enum in the executor's Step 11) — keep it verbatim; Phase 5 routes on it. Each successful executor additionally reports COMMIT, BRANCH, PR_URL (a URL, or null if PR creation failed), MERGE_ELIGIBLE (yes (auto-merge enabled) = guard OK, executor already armed gh pr merge --auto — nothing further needed; yes (auto-merge enable FAILED ...) = guard OK but the gh pr merge --auto call itself errored — needs manual merge or review; held = guard HOLD via the path or todo-frontmatter gate, with the guard's reason line in parentheses; review-required = medium/high/critical/security; unknown = guard couldn't evaluate; n/a = no PR), SHORT_CIRCUIT (a docs/solutions path if a verified solution was reused and the researcher skipped, else none), ADVISOR (green, yellow, red, or skipped), and DEFERRED_WARNINGS. Keep the DEFERRED_WARNINGS lines — Phase 5 surfaces them for triage. Keep the ADVISOR values — Phase 5 tallies them. These accumulate in /tmp/todo-scheduler-state.json's results array as they arrive, rather than per batch — Phase 5 reads that array rather than relying on memory of the whole run.
Phase 5 — Session Summary
First, release all worktree contracts: run bash scripts/declare-worktree.sh --clear. Executors declare their worktrees at Step 0 and remove them at Step 11, but a crashed executor leaves a stale registry entry — and while ANY entry exists, the PreToolUse guards deny YOUR main-checkout git operations (reconciliation, archiving, branch cleanup). --clear is idempotent and safe here: all executors have returned by Phase 5.
Then load the accumulated run results: read results from /tmp/todo-scheduler-state.json (Phase 4) rather than relying on memory of the whole run — this is the authoritative per-todo outcome list for the summary table below. Once read, delete the file: rm -f /tmp/todo-scheduler-state.json.
After the queue is fully drained (or after early termination):
-
Post-session verification — run the full suite one final time:
npm run test:run npm run check:types npm run lint -
Compare test count against the Phase 1 baseline. Flag any regressions (fewer tests passing than before). New tests added by todos are expected and welcome.
-
Print the summary table:
The Branch / PR column shows the PR URL for every todo (all priorities open a PR). Key off each todo's
MERGE_ELIGIBLE:yes (auto-merge enabled)→ "auto-merging on green CI";yes (auto-merge enable FAILED ...)→ "auto-merge failed to arm — needs manual merge or review";held→ "held — guard HOLD (path or todo-frontmatter gate; see the executor's reason line)";review-required→ "needs individual review";unknown→ "guard couldn't evaluate — review by hand". Showpending manual creationif PR creation failed.# Todo Status Branch / PR Review Rounds Notes 1 Extract suggestion generation service success github.com/…/pull/42 1 — 2 Storage facade re-exports success github.com/…/pull/43 2 auto-merging on green CI 3 Remix screen reader announcements blocked — 0 Depends on remix-carousel-badge 4 Fix useCollapsible height test failed — 1 Type error in mock setup 5 Fix calorie rounding utility success pending manual creation 1 PR creation failed — push succeeded -
Print tallies:
Completed: N (list PR URLs; mark "auto-merging on green CI" for `MERGE_ELIGIBLE: yes (auto-merge enabled)`; mark "auto-merge failed to arm" for `yes (auto-merge enable FAILED ...)`; for `held`/`review-required`/`unknown` mark "PR open — needs individual review". Note "PR pending manual creation" for any where PR_URL is null) Blocked: M Skipped: S Failed: F Remaining: X (todos still in backlog after this session) Patterns codified: P Short-circuited: SC (todos that reused a verified solution and skipped research; list the docs/solutions paths) Advisor: G green, Y yellow, R red, S skipped (not available) Final test count: T (baseline was B)Then list quality-flagged todos that were skipped from this run. Using the dropped set carried over from Phase 2 step 6, print them under the heading "Skipped — quality flags — re-author and re-run to include them:" with one line per todo (todo filename + comma-joined flag list). If none were dropped, omit the heading.
Then list gated todos that were skipped from this run. Using the gated set carried over from Phase 2 step 3a, print them under the heading "Gated — blocked_until/human_led (skip until the date passes or a human runs it directly via
/todo-fast):" with one line per todo (todo filename +blocked_untildate orhuman_led: true+ reason). This is a terminal, expected state — not a failure, and never resolved by re-running/todoor by any/goaldirective. If none were gated, omit the heading.Then report auto-merge status. For every
MERGE_ELIGIBLE: yes (auto-merge enabled)PR, nothing further is needed — the executor already rangh pr merge --auto --squash --delete-branch, so GitHub squash-merges it automatically the instant required CI checks pass. List these under "Auto-merging on green CI (no action needed):" with their PR URLs, for visibility only. For anyMERGE_ELIGIBLE: yes (auto-merge enable FAILED ...)PR, the executor'sgh pr merge --autocall itself failed — list it under "Auto-merge failed to arm — needs manualgh pr merge --auto --squash --delete-branch <n>, or individual review:".held/unknown/review-requiredPRs are unaffected by this change — list them exactly as before, under "Needs individual review:", for the user to review and merge by hand.Then list todos awaiting merge and gated dependents. Route executor results on
REASON_CODEfirst; matching on reason-text prefixes is the legacy fallback for a report that lacks the field. Four groups:- Awaiting merge — the Phase 2 skip set plus any executor
skippedresult withREASON_CODE: OPEN_PR_COLLISION(legacy fallback: reason beginsalready implemented; take the PR URL from the reason), each with the PR that must land to unblock it — note whether that PR is auto-merging (nothing to do) or needs individual review (per the reason text) rather than assuming the user must merge it. - Gated on a pending PR — Phase 3 dependents whose dependency HAS an open PR, each with that PR.
- Gated on a dependency (not yet implemented) — Phase 3 dependents whose dependency has no PR (quality-dropped, failed, or never attempted). These do NOT clear on a merge — flag them: the dependency needs re-authoring or a future run first.
- Stale branch — self-clears next run — executor
skippedresults withREASON_CODE: STALE_BRANCH_MERGED(legacy fallback: reason beginsstale todo/) — a leftover branch whose PRs all MERGED; Phase 0's sweep deletes it and the todo re-runs then — no action needed. (A branch whose PR was closed WITHOUT merging is never in this group — that blocks withREASON_CODE: PR_CLOSED_UNMERGEDand lands under "Blocked — needs a one-time manual fix" below.)
None of these are failures. The first two clear on the next run after the user merges; the stale-branch group clears on the next run automatically; only the not-yet-implemented group needs the user's attention.
Producer contract: every listing group in this summary is a terminal state for the run — the overnight
/goalDONE condition indocs/todo-automation-runbook.mdderives from "appears in some listing group", and that paste block's enumeration lists each of these headings by exact name (see its/goalcompletion-condition section). Never add, rename, or remove a listing group without updating that enumeration in the same change.Then list deferred warnings for triage. Collect every non-
noneDEFERRED_WARNINGSentry from all executors and print them under the heading "Deferred warnings — tell me which (if any) to turn into todos:". Nothing here is filed automatically; the user decides. If there are none, omit the heading.Then surface actionable blocks. Dependency-blocks (
REASON_CODE: DEPENDENCY_GATED) do NOT resolve on their own — route each into the gated listings above ("Gated on a pending PR" if the dependency's PR is open; "Gated on a dependency (not yet implemented)" if it has none). If ablockedresult carriesREASON_CODE: ORPHAN_BRANCH,PR_CHECK_FAILED, orPR_CLOSED_UNMERGED(legacy fallback: its REASON containsACTION NEEDED), print that REASON verbatim under the heading "Blocked — needs a one-time manual fix:" — it will re-block every run until the human clears it (the executor's reason includes the exact steps). Do NOT bury it as an ordinary dependency row. In the same section, also print any closed-unmergedtodo/*branches Phase 0 found, both remote (/tmp/todo-closed-unmerged-branches.txt) and local (/tmp/todo-local-closed-unmerged-branches.txt) — each is a rejection signal (its PR was closed without merging); the user decides whether the todo is still wanted. Also print any localtodo/*branches with NO PR in any state that Phase 0 found (/tmp/todo-local-no-pr-branches.txt) — each is a possible orphan from an interrupted executor run (the branch was renamed totodo/<slug>at Step 10 but the process crashed, or the push/gh pr createcall failed, before any PR ever existed); Phase 0 never deletes or pushes these, so the user should inspect each one and either push it and open a PR manually, or delete the local branch if the work is abandoned. Also print anyWARNING:line Phase 0 echoed (agit branch -Dthat failed for a reason other than the branch being checked out elsewhere) verbatim — the branch needs manual inspection. And note if Phase 0 skipped the branch sweep (gh failure or the--limitcap). - Awaiting merge — the Phase 2 skip set plus any executor
-
Print verification result:
Tests: PASS (T tests) | FAIL Types: PASS | FAIL (N errors) Lint: PASS | FAIL (N errors) -
Sweep any executor worktrees still left over — crash backstop only. Phase 4 already removes each todo's worktree immediately when that todo's executor reports (rolling dispatch), so this step should normally find nothing. It exists for the case where an executor crashed before reaching that cleanup, or the run itself was interrupted mid-batch. Force-remove them — a bare
git worktree prunecannot, because they are created locked. Use the non---porcelainform and expand a leading~manually (see the Phase 0 note on why: agitproxy in this environment can rewrite--porcelainoutput into a condensed,~-shorthand format that silently breaks a^worktreeanchor):git worktree list | awk '/\.claude\/worktrees\/agent-/ {sub(/ +[0-9a-f]{4,40} +\[[^]]*\].*$/, ""); print}' | while read -r wt; do wt="${wt/#\~/$HOME}" git worktree unlock "$wt" 2>/dev/null git worktree remove --force "$wt" 2>/dev/null && echo "removed worktree: $wt" done git worktree pruneThis removes worktree directories only — branches and their open PRs are unaffected.
-
Sync the local default branch with this run's merges. A
/todoarchive (and its code change) only reaches the local working copy when the merge propagates back — nothing edits localtodos/in place. After the run, fast-forward localmainso any PR that merged during this session (a user-requested batch-merge) is reflected locally — ff-only, never disturbs parallel work:git fetch origin main --quiet || true CUR=$(git branch --show-current) if { [ "$CUR" = "main" ] || [ "$CUR" = "master" ]; } && [ -z "$(git status --porcelain)" ]; then git pull --ff-only -q origin "$CUR" 2>/dev/null && echo "synced local $CUR with origin" \ || echo "local $CUR not fast-forwardable — pull manually" else git fetch origin main:main 2>/dev/null && echo "fast-forwarded local main ref" \ || echo "local main not fast-forwardable — pull manually when on main + clean" fiOpen PRs land only via auto-merge or the user's review — any still
OPENhere merges later and cannot be pulled now. List each in the summary; the next/todorun's Phase 0 sync picks up post-merge state automatically.
Rules
- Baseline must be green. Never start the run on a broken codebase.
- Max 4 parallel agents. The rolling scheduler enforces this cap per completion, not by chunking into batches — respect the limit to avoid overwhelming system resources and context.
- Sequential when scope is unknown. If a todo mentions no files, it's tagged
must-run-alone— never assume it is safe to run alongside anything else. - Advisor-gate the queue. Before dispatching a queue with 2+
independentitems, run the Phase 3 advisor review — catching an unsafe pairing before agents spin up is far cheaper than untangling stacked PRs after. - Top-level verification happens in Phase 5 only. Do not run an extra orchestrator-level
npm run test:run/check:types/lintpass while executors are in flight. Each executor still performs its own scoped verification inside the worktree before reporting success, and the orchestrator runs one final repo-level verification pass at the end. - The executor agent does the work. This orchestrator only triages, dispatches, and summarizes. Never implement todo changes directly.
- Archive happens in the executor. Completed todos are moved to
todos/archive/by the executor agent, not by this orchestrator. - Report everything. Every todo in the queue must appear in the final summary table, even if skipped or blocked.
- Self-cleaning. Phase 0 force-removes leftover worktrees and deletes both remote AND local
todo/*branches whose PRs are all merged (a branch whose PR was closed WITHOUT merging is a rejection signal — surfaced in Phase 5, never auto-swept, for both remote and local); Phase 4 removes each todo's worktree immediately on completion, and Phase 5 sweeps any stragglers as a crash backstop. The user must never have to clean uptodo/*branches — local or remote — oragent-*worktrees by hand. - Auto-merge only through the guard. Executors enable GitHub's native
gh pr merge --auto --squash --delete-branchONLY whentodo-automerge-guard.shreturns exit 0 (low priority, non-security, safe-path-only) — it then merges itself once CI is green, no orchestrator or user step. Every other PR (held,unknown,review-required) stays open and is never auto-merged; the user reviews and merges those individually. - Auto-sync local
main. Phase 0 fast-forwards localmainat the start (catching merges from prior sessions, which also stops the backlog from re-picking an already-archived todo) and Phase 5 fast-forwards again at the end (catching this run's merges). Always ff-only so parallel work is never disturbed — the user must never have togit pullby hand to see a completed todo archived locally.