Imported from vinhnxv/skills (
skills/codex/backlog-loop/SKILL.md). Install upstream withnpx skills add vinhnxv/skills --skill backlog-loop. Copyright stays with the author.
Clear this repo's issue backlog autonomously, one batch at a time, until CENSUS below shows no issue left in a category this loop is responsible for. An empty bd ready list is never that proof. Never ask the user anything; when a choice arises take your recommended option and record it.
A batch is 1..N sibling issues sized against a complexity budget, so one LFG-compatible pipeline is neither a wasted trip for one 15-minute chore nor an unreviewable multi-hour PR. One batch = one plan = one branch = one PR = one merge.
This procedure is the outer loop and shipping authority. For every batch, read the available compound-engineering:lfg skill and drive its current stages through the documented ce-plan, ce-work, review, browser-test, ship, and babysit seams. Do not invoke LFG as an indivisible child: the loop must retain control at the plan boundary to resize the batch, and must be able to replace an unavailable CI watch with local gates. Resolve every named child skill against the host's available-skills list and use host-native invocation; never guess slash or dollar syntax. Never bypass a child skill's applicable gates.
PREFLIGHT
Resolve from the repo, report once, then start. Stop if a REQUIRED item won't resolve.
Choose a unique <run-id> first (an ISO UTC timestamp is enough): the process-hygiene item below stamps it onto every command this loop launches, and the tracker marker below carries it.
- Child skills (REQ): resolve every skill this procedure invokes against the host's available-skills list --
compound-engineering:lfg,compound-engineering:ce-plan,compound-engineering:ce-work,compound-engineering:ce-simplify-code,compound-engineering:ce-code-review,compound-engineering:ce-test-browser,compound-engineering:ce-doc-review,compound-engineering:ce-commit-push-pr,compound-engineering:ce-babysit-pr. Any name that does not resolve -> stop before any other item, and name every unresolved skill in the stop report so it reads as a missing prerequisite rather than a tracker or forge failure. Resolve first: this is the cheapest check here and the only one whose failure would otherwise surface at step 5, after issues are claimed and a branch is cut. - Tracker (REQ):
bd primeandbd readymust both work. Anything else -> stop, naming Beads as the missing prerequisite. Every step below issues literalbdcommands for claims, estimates, metadata, notes, and closure; there is no supported mapping onto another tracker, and improvising a partial one leaves tracker state half-mutated at the first unmapped operation with no defined rollback. When repo instructions make Beads the sole task tracker, treat any platform-local task-list capability requested by a child skill as unavailable; do not create a second source of task state. - Sync:
bd dolt pushonly if a dolt remote exists; else skip. - Forge (REQ):
ghauthed on this repo. Not GitHub -> stop. Resolve<remote>: the git remote whose fetch URL names the sameowner/repothatgh repo view --json nameWithOwnerreports. No remote matches, or more than one does -> stop. Also stop when the matching remote is not namedorigin:ce-commit-push-pr's pinned contract pushes with a literalgit push -u origin HEAD, so a correctly resolvedupstreamwould pass every check here and then fail at shipping with the batch already claimed and built. Resolving the remote rather than assuming it is still what makes the mismatch a preflight stop instead of a late failure, and it is what keeps<remote>/<default>correct if a checkout has several GitHub remotes. Use<remote>for every fetch and every<remote>/<default>reference below. - Default branch (REQ):
gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'. Require one non-empty unqualified branch name such asmain, neverorigin/mainand never a hardcoded literal. - CI availability (REQ, non-fatal): refresh remote refs with
git fetch <remote> --prune, resolve<default-sha>withgit rev-parse <remote>/<default>, then enumerate EVERY run for that exact SHA -gh run list --branch <default> --commit <default-sha> --limit 100 --json workflowName,conclusion,status,createdAt,headSha- and reduce it to the latest attempt perworkflowName. A commit carries one run per workflow, so reasoning about a singular newest run lets a fast lint workflow speak for a slow test workflow that is still running or already red. CI is UNAVAILABLE if any of: no run exists for that exact SHA; every matching run concludesstartup_failure; or the Actions API returns a billing, quota, or spending-limit error. Record the initial observation, but step 1 re-probes every current trunk SHA because a backlog batch can add or repair CI. UNAVAILABLE is expected and pre-authorized; never ask the user to fix billing. - Merge capability (REQ): query
gh api repos/{owner}/{repo}/branches/<default>/protection. 404 -> unprotected. A 403 is unprotected only when the response exactly says branch protection requires a paid plan or public repository; any other 403 is an authorization failure and stops. On 200 inspect.required_status_checks: none -> direct merge works; required checks with initial CI available -> continue because the pipeline waits to CI-decided and step 6 requires green; required checks with initial CI unavailable -> stop because they can never turn green. If a later batch resolves<batch-ci>=offwhile required checks exist, stop before claiming it. Merge queue is a separate query, because it is not in this response:gh api --paginate --slurp repos/{owner}/{repo}/rules/branches/<default>,[]when no rule applies. Paginate and flatten: the endpoint defaults to 30 per page, and an organization contributing rules alongside the repository can pushmerge_queueonto a second page that an unpaginated read never sees. Any entry whosetypeismerge_queue-> stop. On such a branchgh pr mergeenables auto-merge or enqueues the PR instead of merging it, which step 6 forbids and cannot verify. This endpoint carries the SAME paid-plan carve-out as the protection query above and for the same reason -- measured 2026-09-04, a private repository under a free organization plan answers 403Upgrade to GitHub Pro or make this repository publicon/protection,/rulesetsand/rules/branches/<default>alike -- so a 403 whose message is exactly that reads as no merge queue, because a merge queue is configured only through branch protection or a ruleset and neither can exist on that plan and visibility. Any other 403, and any other response, stops. Never pass--admin. - Drop
--delete-branchif the repo forbids branch deletion. - Quality gate set (REQ): read CLAUDE.md/AGENTS.md/CONTRIBUTING.md/README at the commit being gated, with
git show <sha>:<path>or from inside<clean-tree>, never from the working tree. Isolating where a gate RUNS while selecting it from a checkout that deliberately keeps<excluded-paths>dirty leaves the whole point undone: an uncommitted edit to AGENTS.md swappingnpm testfornpm run smokemakes the loop record and run the weaker command against the exact commit and merge code the committed gate rejects. Re-resolve the set separately for the trunk SHA, the batch head, and the merge commit. Record every unconditional gate plus every path-conditional gate. Else use the project's test command. Do not flatten a conditional gate into one command or omit it. For example, a repo declaring one command that runs on every change plus a second that runs only when changed files touch its data-access packages contributes two gates, not one: record the conditional gate together with the paths that trigger it, and evaluate that trigger against each batch's actual changed files. Child verification, post-merge verification, and the final report use this same gate set. - Gate coverage: on the first
<batch-ci>=offobservation, read both.github/workflows/*.ymland.github/workflows/*.yamlwhen present. List every command each job runs and compare it against the full gate set. Report any command CI would have run that the gates do not. While<batch-ci>=offsuch a gap is not informational: nothing else verifies that command, so every uncovered workflow command joins the gate set actually run, and one that cannot be run locally stops the batch rather than merging unverified. Re-resolve the whole gate set whenever a batch changes CLAUDE.md/AGENTS.md/CONTRIBUTING.md/README or anything under.github/workflows/; a catalog read once at preflight goes stale the moment the loop edits the files that define it. - Process hygiene (REQ): record
<cores>fromsysctl -n hw.ncpu, ornprocwhere that is the platform's command. Every gate, test, build, or app command this loop runs is launched non-interactively (CI=1, watch and UI modes off), inside its own process group, and under a hard deadline<gate-timeout>=20m:perl -e 'setpgrp(0,0); exec @ARGV' -- timeout -k 30s <gate-timeout> <command>. Wheretimeoutis absent, keep the process group and enforce the deadline by killing that group. ExportBACKLOG_LOOP_RUN=<run-id>into every such command so ownership stays provable after a runner dies. Append every launched leader PID to<owned-pgids>, the set the step 1 machine guard resets each iteration. Never leave a watcher, dev server, or REPL alive past the command that needed it; a command that returns while its group still exists is a reap target, not a success. - Constraints: read the repo's CLAUDE.md/AGENTS.md non-negotiables; else its documented conventions.
- Worktree safety: inventory
git status --shortbefore any branch move and save every pre-existing dirty path as<excluded-paths>. Carry the list as context through planning, simplification, and review-fix commits; when non-empty, pass the documentedexclude:<comma-separated-paths>carrier toce-commit-push-pr. Do not pass that carrier to a child that does not document it:ce-workinventories pre-work WIP itself and must block on collisions. Verify excluded paths remain uncommitted before every push. Never reset, clean, stash, or use a tree-wide checkout to make the tree look clean. If planned work collides with an excluded path, stop the batch rather than absorbing or discarding it. - Owner-decision issues: find by content (body asks for a product or design call), not by hardcoded id. The scan itself does NOT run here -- it runs in ITERATION step 2, immediately after that iteration's CENSUS, over the issues that census classified as neither
human-gatenorlabel-defect. It cannot run in preflight because no census has classified anything yet, and a content scan that runs first is exactly how a gate asking for a production approval gets read as a product call and shipped.## HUMAN GATESowns why, and the order is not optional.
CLEAN-TREE GATE RUN
Every gate whose result authorizes a merge -- the step 1 trunk gate, the step 5 pre-PR gate, and step 7 post-merge verification -- runs against a throwaway worktree at an exact commit, never in the working tree.
The working tree deliberately keeps <excluded-paths> dirty and is forbidden from cleaning them. Subtracting those paths from a changed-file list keeps them out of the PR; it does not keep them out of the build. A dirty config, stub, or source patch sitting beside the code is loaded by the very gate that decides whether to merge, so a green result there says nothing about the commit that ships.
<clean-tree>is<worktree-root>/<sha>, where<worktree-root>is a single run-owned directory outside this repository named after<run-id>, recorded once in the ledger asbacklog_loop_worktrees. A path invented per gate has no identity after a crash: the worktree stays registered, its installed dependencies stay on disk, and no later run can tell which registered worktree was whose.git worktree add --detach <clean-tree> <sha>.- If the gate set needs installed dependencies, run the repo's documented install or bootstrap command inside
<clean-tree>first, preferring its frozen/locked form. Then requiregit -C <clean-tree> status --porcelainto report no change to a tracked file. A bootstrap that regenerates a checked-in artifact turns the gate into a test of locally regenerated content that is then deleted with the worktree, while the stale committed version is what merges. Any tracked change -> the gate has not passed. A gate that cannot execute there has not passed either, and neither may be recorded as green. - Run the gates there under the same process-hygiene rules, including the run token and
<owned-pgids>. git worktree remove --force <clean-tree>andgit worktree prunewhen they finish, on the green path and the red path alike.
Tag every claimed member with bd update <id> --set-metadata backlog_loop_run=<run-id>; remove that metadata from members dropped before implementation. Final reporting queries this run marker instead of scanning all historical closed issues.
Do not resolve a plans directory here. ce-plan, under LFG's plan-brief.md contract, resolves its own artifact root and reports the path it wrote; step 5 uses that reported path and nothing else.
BATCH BUDGET
TARGET = 90 minutes of estimate # fill a batch toward this
CEILING = 150 minutes # never admit a member that crosses this
CONCERNS = 4 members maximum # review cost scales with concern count, not only size
Estimates come from the tracker's own field (bd show --json -> estimated_minutes). When an issue has none, derive one from this rubric and write it back with bd update <id> --estimate <minutes> so the next session starts from data instead of a guess:
| Shape of the issue | Minutes |
|---|---|
| Docs/config/copy only, one file | 15 |
| Bug with a clear repro, one module | 30 |
| Task with 1-3 acceptance criteria, one module | 45 |
| Feature, or anything crossing modules | 90 |
| Vague, or touching auth/payments/migrations/external contracts | 120, and treat as solo |
STATE
State lives in the tracker. Re-read bd ready every iteration; never work from a remembered list. Also inspect non-epic in_progress issues and split them by the PRESENCE of backlog_loop_run metadata, never by its value. A fresh <run-id> is chosen every invocation, so an issue left behind by an interrupted earlier run can never carry the current one; matching on the value would file this tool's own abandoned claims as somebody else's and wedge the loop permanently on the first crash.
- Marker present: an earlier run of this procedure claimed it and did not finish. Recover it through THE RUN LEDGER below.
- Marker absent: externally owned. Never steal it. If no ready work exists and one of these remains, stop and report that the backlog is not clear.
That split is an ownership test and CENSUS extends it into a full accounting of every non-closed issue. Read CENSUS before deciding that anything is finished.
THE RUN LEDGER
Every fact a resumed run needs lives in tracker metadata on the batch's own members, because that is the only store that survives the process dying between two commands. Write these with bd update <id> --set-metadata for every member, at the moment the phase is reached and before the action the next phase depends on:
| key | written at | value |
|---|---|---|
backlog_loop_run |
CLAIM | <run-id> |
backlog_loop_phase |
each transition | claimed, built, shipping-requested, pr-open, merge-requested, merged, or verified |
backlog_loop_heartbeat |
CLAIM, every iteration step 1, and before CENSUS's first write | current ISO UTC time |
backlog_loop_ci |
step 1, before any gate | on or off, the route this batch was gated under |
backlog_loop_worktrees |
first CLEAN-TREE GATE RUN | <worktree-root> |
backlog_loop_base |
BASE | <batch-base-sha> |
backlog_loop_branch |
pipeline step 6 | the branch the work sits on |
backlog_loop_head |
pipeline step 6 | <batch-head-sha>, the gated commit |
backlog_loop_pr |
pipeline step 7 | the PR URL |
backlog_loop_merge |
step 6 | <merge-sha>, once the PR reports MERGED |
backlog_loop_cause |
every blocked write, and CENSUS backfill |
needs-person, transient:trunk-moved, or transient:merge-precondition |
backlog_loop_attempts |
every blocked write |
count of blocks this issue has taken, incremented never reset |
backlog_loop_census |
CENSUS, on every issue it writes to | <census-run> |
backlog_loop_census_heartbeat |
CENSUS, before each mutation | the census token and the ISO UTC time, so one census's heartbeat can be told from another's |
backlog_loop_quarantine |
CENSUS gate repair | the census token and time that freed the issue |
backlog_loop_edge_removed |
CENSUS gate repair | space-separated ids at the far end of every edge removed from this issue; the records themselves are notes |
backlog_loop_edge_ack |
AUTHOR ONLY, never this procedure | space-separated dependent ids whose removal a person has acknowledged |
LIVENESS, before any recovery. A marker proves an earlier run claimed the issue; it does not prove that run stopped. Read backlog_loop_heartbeat: less than 30 minutes old, or any live process carries that run's BACKLOG_LOOP_RUN token -> another invocation is still working. STOP and report it. Do not reap, reclaim, retire a PR, or claim anything. Two overlapping scheduled invocations would otherwise each treat the other as wreckage, kill its running gates, close its PR, and rebuild its work while it is still moving toward its own merge.
RECOVERY, by the recorded phase. Read it before touching anything; the whole point is that an interruption between merging and closing must not look like an interruption before merging.
mergedorverified: the merge is proven. Finish the interrupted close. Post-merge verification is required unless the phase is alreadyverified, and it runs underbacklog_loop_ci, not under a fresh probe: the interrupted batch may have merged the very workflow that now makes CI look available, so re-deriving the route would skip the local gate that batch was actually merged on. Missingbacklog_loop_ci-> run the clean-tree post-merge gate. Then the calibration note andbd closeagainstbacklog_loop_pr.merge-requested: the outcome is unknown, which is exactly what this phase exists to record. Querybacklog_loop_pr.MERGED-> treat asmergedabove. Any other state -> retire the PR, then reclaim.shipping-requested: the push and PR creation were requested but their identifiers were never recorded, so a PR may exist under a URL the ledger does not hold. Search for it by the recorded branch and head -gh pr list --head <backlog_loop_branch> --state all --json number,url,state,headRefOid,baseRefName- and require exactly one match.MERGED-> treat asmergedabove. Open -> retire it, then reclaim. No match -> reclaim. More than one match -> STOP rather than guess which one this run created.pr-open,built, orclaimed: nothing shipped. Reclaim, retiring the PR first whenbacklog_loop_pris set. Never resume a half-built branch into a merge; no gate receipt survived the interruption to say it was ever green.
Retiring a PR means gh pr close <backlog_loop_pr> --comment "superseded: interrupted backlog-loop run <old-run-id>" -- close, never merge. Reclaiming an issue while its PR stays open is how the same work ends up in two open PRs: the issue returns to bd ready, a later batch builds and merges a second PR for it, and the abandoned one still sits there mergeable by a human, carrying changes no current gate ever approved. Leave the branch itself in place and name both it and the closed PR in the FINAL REPORT, so nothing is silently discarded.
Reclaim means bd update <id> --status=open --assignee="" --unset-metadata backlog_loop_run --append-notes="reclaimed from interrupted run <old-run-id> at phase <phase>", plus unsetting every other ledger key, returning the issue to bd ready.
Before any of that, and only after LIVENESS has proved the old run is gone, reap its escaped processes by its <old-run-id> token exactly as step 8 does; a killed runner leaves its children behind, and they will contend with this run's gates. Remove its backlog_loop_worktrees directory and run git worktree prune, so an interrupted run's worktrees and their installed dependencies do not accumulate on disk until a later gate cannot create one.
Two things are deliberately NOT in the ledger. The consecutive-blocked and consecutive-merge-failure counters reset on a new invocation: they bound one run's thrash, and a human choosing to start the loop again is a new decision, not a continuation. <owned-pgids> is in-memory by design, which is why every command also carries the run token - the token is what makes a dead run's processes findable when its PID list is gone. A failed or blocked attempted batch is marked blocked, so it cannot be selected again silently. backlog_loop_attempts is the deliberate exception to that reset rule: it bounds one ISSUE across its whole life rather than one run's thrash, so a scheduled loop cannot rebuild the same failure forever. CI is batch-scoped and re-resolved from the exact current trunk SHA every iteration; it may change in either direction.
CENSUS
One classification pass over the whole tracker, written once and read by both entry points. Both evaluate the same recorded fields under the same stated precedence, so a category difference between a diagnostic run and a loop run over an unchanged backlog is a defect -- in this section, the fixture, or the tracker -- and never a permitted outcome. A shared section removes drift between two texts; it does not by itself make a model deterministic, which is why every test below is a lookup over recorded fields rather than a reading. The loop runs it at the termination decision; the companion census prompt runs it alone. An empty bd ready list has at least six causes and only one of them is "finished", so nothing below infers termination from that list.
Choose a <census-run> token the same way as <run-id>. It is a separate key from the claim marker because "claimed by this run" is itself a category below; reusing one key would make the categories overlap.
ENUMERATE. bd list --all --limit 0 --include-gates --json, then discard status=closed and issue_type=epic rows. Every flag is load-bearing: without --limit 0 the query returns at most 50 rows, without --include-gates it omits native gate issues entirely, and without --all it omits pinned issues, because --pinned filters a different attribute and does not surface them. --all also returns the repository's whole closed history, which is why discarding closed rows is part of the query and not an afterthought. Do NOT pass --include-infra or --include-templates: agent, role, message, and template beads are not work, have no category below, and would make the exhaustiveness claim false. Say so on the census header line, so an operator reading a count knows what it counted.
bd ready --explain --json is the AUTHORITY for the dependency question -- which issues are dependency-blocked, and by what -- and not merely a source of blocker names. Do not re-derive it from edges. The tracker propagates blocking through a blocked parent, so a child whose only edge is parent-child to an open epic is offered as ready when that epic is ready and withheld when it is blocked, and no walk over an issue's own edge list can see that. Re-deriving it produced four ready issues on a backlog where the tracker reported one.
That explanation covers OPEN issues only. A blocked issue appears in neither of its buckets, so every category below that turns on status is read from the issue's own stored status.
METADATA IS NOT IN THE ENUMERATION. bd list --json returns neither a metadata object nor a dependencies array; it carries dependency_count and nothing to resolve it with. So the marker-bearing rows below are answered by one bd list --has-metadata-key <key> --json per marker key -- backlog_loop_run, backlog_loop_cause, backlog_loop_quarantine, backlog_loop_heartbeat, backlog_loop_census_heartbeat -- intersected with the enumeration. That is five queries whatever the backlog's size, where reading the same fact per issue would be one bd show per issue and would put the census's cost on the tracker's.
BACKFILL, once per issue, BEFORE the precedence walk below. A blocked issue carrying backlog_loop_run but no backlog_loop_cause predates this section: it was written by an earlier form of the procedure that recorded only a note. This runs first because rows 6 and 7 read the cause key, and a blocked issue that reaches the walk without one would otherwise be filed by a key that is merely missing. It covers only blocks that already carry a marker; a block with no marker at all is older still, and row 10 handles it without writing anything.
- The note text is not in the enumeration output, so read it with one
bd show <id> --jsonper affected issue. That is the one place a per-issue query is allowed, and it is bounded twice over: onlyblockedmarker-bearing issues with no cause qualify, and an issue that has a cause key is never re-read. - Classify it with step 6's reason patterns only. Step 7's rule is unconditional (
needs-personwhatever the gate said), so it contributes a note prefix to recognize, not a pattern to match: a note beginningpost-merge verification failed:backfills asneeds-person. So does a note matching nothing. - Derive the cause in BOTH modes; PERSIST it only when the WRITE GATE below allows. A diagnostic run classifies on the derived value and writes nothing, which is what lets
--readonlyhold. Persisting meansbacklog_loop_cause=<derived>plusbacklog_loop_attempts=1. - This is the one place the procedure reads its own prose to decide a class, and the bounds above are what stop the exception spreading.
CLASSIFY. Every enumerated issue gets exactly one category. Walk this precedence top-down and stop at the first match, so an issue satisfying several is filed once and the same issue is filed the same way on every run:
| # | category | test |
|---|---|---|
| 1 | human-gate |
carries the human-gate label, or issue_type is gate |
| 2 | label-defect |
title begins [HUMAN] but neither of the above holds |
| 3 | quarantined |
carries backlog_loop_quarantine |
| 4 | claimed-other-run |
backlog_loop_run present with a value other than <run-id>, and that run is LIVE by THE RUN LEDGER's liveness test |
| 5 | external-wip |
status=in_progress and no backlog_loop_run |
| 6 | self-blocked-needs-person |
status=blocked, backlog_loop_run present, and backlog_loop_cause is needs-person or still absent |
| 7 | self-blocked-transient |
status=blocked, backlog_loop_run present, backlog_loop_cause begins transient |
| 8 | claimed-this-run |
backlog_loop_run equals <run-id>, and status is not blocked |
| 9 | abandoned-claim |
backlog_loop_run present, that run is NOT live, and status is not blocked |
| 10 | legacy-blocked |
status=blocked, no backlog_loop_run, and no unmet dependency |
| 11 | dep-blocked |
an unmet dependency, by --explain's verdict above |
| 12 | hooked |
status=hooked |
| 13 | pinned |
status=pinned |
| 14 | deferred |
status=deferred |
| 15 | ready |
everything left |
Every ordering above is load-bearing, and each one exists because the opposite order sends work somewhere destructive:
- Rows 1 and 2 sit above everything because a gate must never be claimed whatever else is true of it, and a mislabeled gate must not fall through to
readyand get built. - Row 3 sits above the rest because a quarantined issue is waiting on a person's eyes, not on the loop, and any lower row would put it back in the pool the quarantine exists to keep it out of.
- Rows 6 and 7 sit above row 8 so that an issue THIS run blocked is filed as blocked rather than as still claimed. Below row 8 they would be shadowed entirely, and every self-block this run wrote would stay in the loop-responsible set, so the run could never report the backlog clear.
- Rows 6 and 7 also sit above rows 10 and 11 because an issue this loop blocked can also carry a dependency edge, and the loop's own wreckage is the actionable half of that pair.
- Row 10 sits above row 11 because a
blockedissue that no dependency explains is not dependency-blocked, and filing it as though it were hides it among issues that will clear themselves. It is a block written by an earlier form of this procedure, before any marker was recorded -- the exact population the Problem Frame names -- and it is recognized the way everything else here is, from the ABSENCE of fields rather than from reading the note: no marker to own it, and no unmet dependency to explain it. The note is reported, never tested. A block a person wrote by hand has the same shape and gets the same treatment, which is correct: both need a person, and neither is this loop's to reopen. - Row 9 exists because rows 4, 5, and 8 between them do NOT cover an issue left
in_progressby a run that has since died: row 4 requires that run to be live, row 5 requires no marker at all, and row 8 requires the marker to be this run's. Without row 9 such an issue reaches row 15 and is offered as ready work, and the loop claims an issue that may still have an open PR -- the exact double-shipping THE RUN LEDGER warns about.
An abandoned-claim is never selected directly. It is this procedure's own wreckage and it goes through THE RUN LEDGER's RECOVERY, which reads backlog_loop_phase and decides per phase whether to reclaim, adopt the existing PR, or leave it; only what recovery returns to open can be picked in a later iteration.
Two different rules govern the marker, and confusing them is what wedges the loop. ## STATE's rule -- split by the PRESENCE of backlog_loop_run, never its value -- decides ownership: present means this procedure's own work, recoverable through THE RUN LEDGER; absent means externally owned and never to be stolen. Rows 4, 8, and 9 are not an ownership question but a liveness one, and they are the ONLY place the marker's value is read, always together with backlog_loop_heartbeat under the liveness test. A stale marker with a dead heartbeat therefore still classifies as this procedure's own wreckage -- as row 9 for an unfinished claim, as rows 6 and 7 for a block -- exactly as ## STATE requires.
EMIT, always, in both modes. One line per enumerated issue, in this exact shape, so a fixture suite and an operator read the same output:
census <id> | <category> | <cause>
<cause> names why the issue sits where it does. Every category has one, so no line is ever left with a blank third field:
| category | <cause> |
|---|---|
human-gate |
label or native |
label-defect |
the label to apply, human-gate |
quarantined |
the census token recorded in backlog_loop_quarantine |
claimed-other-run |
that run's id |
external-wip |
assignee:<name>, or unassigned |
self-blocked-needs-person / self-blocked-transient |
the recorded backlog_loop_cause value |
claimed-this-run |
the recorded backlog_loop_phase |
abandoned-claim |
the recorded backlog_loop_phase, or none when unset |
legacy-blocked |
no-marker-no-edge |
dep-blocked |
the blocking issue's id, as --explain reports it |
hooked / pinned / deferred |
none |
ready |
none |
One line per issue and nothing else on it.
ACCOUNT FOR THE COUNT before reporting anything. The census claims to have accounted for every issue, so it verifies that claim rather than asserting it: the number of census lines must equal the number of rows the enumeration kept. They differ only if a per-issue read failed, and such a read can fail without saying so -- one bd show in fifty-three returned nothing on a live tracker, silently, and a run that did not count would have reported a backlog one issue smaller than it is. On a mismatch, name the missing ids and stop; do not report a census, and do not enter a mutation pass.
Print the header line first, beginning with the literal census-run and carrying the census token, the counts per category, and the flags the enumeration ran under. The two prefixes are fixed and distinct on purpose: a header that also began census would be indistinguishable from a data line to anything counting them, and a reader downstream would report one more issue than the tracker holds.
LOOP-RESPONSIBLE SET. Exactly five categories are the loop's to clear: ready, claimed-this-run, abandoned-claim, self-blocked-transient, and a dep-blocked issue whose blocker is itself in one of those. Everything else is somebody's or something else's. quarantined and legacy-blocked are deliberately NOT in the set, both because they wait on a person. Counting quarantined would make a run that quarantined anything unable to ever report the backlog clear. legacy-blocked is excluded for a stronger reason: reopening one means overriding a decision an earlier run recorded and a person has not revisited, and the recorded reasons are routinely ones no later run can re-derive -- a release boundary the epic declares, a collision with work its caller owned, a unit no agent can build. Reopening those rebuilds what somebody deliberately stopped. The backlog is clear when no issue sits in that set -- never because bd ready came back empty.
Walking dep-blocked transitively meets the cycles this procedure reports and never repairs, so the walk carries a visited set, counts each issue at most once, and treats a repeat visit as the end of that branch plus a reported cycle. Walk the blocked_by lists in the bd ready --explain --json output already taken above, which reports its own cycle count; the enumeration carries no edges to walk.
WRITE GATE, covering EVERY write this section makes -- the BACKFILL persist above and both mutation passes below, not the passes alone. Each is skipped entirely, with no partial application, when either of these holds: the run is a diagnostic run, or any other run of this procedure holds a live heartbeat. The <repair-ceiling> is a third stop but a narrower one: it bounds GATE REPAIR PASS only, and never stops REOPEN PASS, which is bounded per issue by backlog_loop_attempts. Letting an unrelated run of gate repairs stall recovery would leave blocked work unreopened for a reason that has nothing to do with it. A skipped write still classifies, still emits, and still reports every action it would have taken.
"Another run holds a live heartbeat" means either of two things, and both are decided from a field that says WHOSE it is: a backlog_loop_heartbeat under 30 minutes old on an issue whose backlog_loop_run is not <run-id>, or a backlog_loop_census_heartbeat under 30 minutes old whose token is not <census-run>.
Before either pass, publish the second of those: bd update <id> --set-metadata backlog_loop_census_heartbeat="<census-run> | <iso-time>" on the issue that pass is about to write to. Without it the check above is unarmed -- backlog_loop_heartbeat is first written at CLAIM, so two invocations that start together both find no live run and both mutate. The value carries <census-run> and not a bare timestamp because ownership has to be decidable from the field alone: a run that published an unattributable heartbeat and then re-read it would read its OWN heartbeat as a competitor's and skip every pass it had just armed, which is a deadlock that gets worse the more correctly the rest of the rule is followed. Re-read before each individual mutation rather than once for the whole pass, so a run that went live in between is not invisible for the rest of it; a value whose token is <census-run> is this run's own and is ignored.
Repairs are idempotent, not ledger-phased. The ledger's premise is that recovery facts live on a batch's own members, and a census has no members, so there is no natural key to phase against. Every repair below instead re-reads current state and does nothing when it is already applied, which makes recovery "run the census again" and leaves no partial-completion state to reason about.
REOPEN PASS. For each self-blocked-transient issue, in ascending id order:
backlog_loop_attemptsat or above<attempt-ceiling>=3-> rewritebacklog_loop_cause=needs-person, name it in the report, and never reopen it again. The consecutive-blocked counters reset per invocation by design; this one must not, or a scheduled loop rebuilds the same failure forever.- Otherwise prove the recorded cause is gone before touching it. The cause is a recorded value, not a reading of the note:
transient:trunk-movedis gone when<remote>/<default>is currently green underbacklog_loop_ci's recorded route.transient:merge-preconditionis gone when the recorded PR isCLOSEDand no PR is open on the recorded branch. AMERGEDPR is NOT that proof, even though a merged PR is also a closed one: the work is already on trunk, so reopening rebuilds shipped code. Read the PR's state explicitly and requireCLOSED; onMERGED, rewrite the cause toneeds-personand report it. A baretransientwith no subtype, or any cause with no proof the census can evaluate on its own, is not a transient cause at all: record itneeds-personrather than leaving it to expire on a condition nothing can satisfy. - Proven -> one atomic update, following the reclaim shape:
bd update <id> --status=open --assignee="" --unset-metadata backlog_loop_run --append-notes="reopened by census <census-run>: <cause> no longer holds", plus unsettingbacklog_loop_phase,backlog_loop_base,backlog_loop_branch,backlog_loop_head,backlog_loop_pr,backlog_loop_merge,backlog_loop_ci, andbacklog_loop_worktrees. Reopening preserves every metadata key it does not name, so a stalebacklog_loop_prleft behind would be read as this run's own PR at the next claim.backlog_loop_causeandbacklog_loop_attemptsare deliberately NOT cleared: the attempt count is what bounds the cycle. - A block written because post-merge verification failed is never reopened, whatever trunk currently looks like. Its code is already on trunk and its phase is already
merged; the proven-merge rule in THE RUN LEDGER says that work is finished, not rebuilt. Step 7 records itneeds-personfor exactly this reason.
Stamp backlog_loop_census=<census-run> on every issue this section writes to, as part of that same write rather than as a second command. It is what lets ## FINAL REPORT find the issues a census touched without re-deriving them, and stamping only the issues actually written keeps the marker meaning "this census changed this issue".
GATE REPAIR PASS. See ## HUMAN GATES.
REPORT. Both modes end with the census lines, the loop-responsible count, every gate with what its person must do, every labeling defect with the exact label to apply, every legacy-blocked issue with the first line of its note -- that note is the only record of why it was stopped, and a person cannot decide without it -- every reported cycle, and -- in a loop run -- every repair and every reopen this run actually performed, each naming both issues, the marker state relied on, and where its restorable record sits. A run that mutates a person's tracker and does not say so is the failure this section exists to prevent, so that list is printed whether the run cleared the backlog or not.
For each ready issue also print how many issues it would unblock, counted transitively over the same visited-set walk, as two figures: those the loop can clear unaided, and those that become reachable only once named gates are released. One combined number hides the pairing an operator most needs -- a ready issue sitting in front of a gate is where a person-minute buys the most agent work -- so name those gates beside it.
DIAGNOSTIC RUN. Choose a <run-id> the same way an ordinary run does even though nothing will be claimed under it, so rows 4, 8, and 9 have a token to compare against. Classification, emission, and reporting are otherwise identical to a loop run; only authority differs. Every bd command carries the global --readonly flag, so a write is refused by the tracker rather than merely avoided by this prose. The run branches nothing, pushes nothing, and merges nothing, and it needs only the tracker and constraints preflight items -- the forge, default-branch, CI, and merge-capability items do not gate it, because diagnosing a backlog must work in a repository this loop could never merge into. It reports every repair and reopen it would perform. The operator authorizes those by starting an ordinary loop run; nothing here asks a question mid-run.
ITERATION
-
TRUNK HEALTH. MACHINE GUARD, before touching git: run step 8 REAP against the previous batch's
<owned-pgids>, which also covers an iteration that stopped early without reaping. Then read the 1-minute load average fromsysctl -n vm.loadavg, oruptimewhere that is the platform's source. Above2 x <cores>-> reap again, re-read after 60s, and STOP with a report if it is still above that line: opening a batch on a saturated machine stacks another full test run on top of whatever is already pinning the CPU, and every gate below then times out on contention instead of on code. Green -> record<iteration-start>as the current time, reset<owned-pgids>to empty, and refreshbacklog_loop_heartbeatto the current ISO UTC time on every member this run currently holds. A stale heartbeat is what lets the next invocation tell an abandoned run from a live one, so it has to be renewed on a schedule, not written once at CLAIM. Update trunk safely withgit fetch <remote> --prune,git switch <default>, andgit merge --ff-only <remote>/<default>. Never use reset or a tree-wide checkout. Then requiregit rev-parse HEADto equalgit rev-parse <remote>/<default>.--ff-onlysucceeds and changes nothing when the local branch is AHEAD of the remote, so it does not by itself prove the two match; a local default branch carrying unpushed commits passes it untouched. Ahead -> STOP and report those commits without moving them. They are the user's, they were never offered to this loop, and branching from that HEAD would push and self-merge them inside the batch's PR. Resolve the current<remote>/<default>SHA and repeat the exact-SHA CI probe from preflight. Matching usable runs -> set<batch-ci>=on. No matching run,startup_failure, or billing/quota/spending failure -> set<batch-ci>=off. This decision is only for the current batch; re-probe next iteration so a batch that adds CI can move the following batch from off to on. Once the batch has claimed its members, write the resolved route tobacklog_loop_cibefore any gate runs under it.<batch-ci>=on: take the latest attempt of EVERY workflow for that SHA, not just the newest one overall. Any of them failed from code -> STOP and report; never pile another merge onto a broken trunk. Any of them still in progress -> wait under a bounded deadline of 10 minutes, re-reading every 30s, and require all of them terminal and green; still undecided at the deadline -> STOP and report. An undecided run is not a green one: later probes only ever look at their own exact SHA, so a trunk failure ignored here is never observed again and every following batch stacks onto it.<batch-ci>=off: LOCAL TRUNK GATE: run the unconditional quality gates through CLEAN-TREE GATE RUN at the current<remote>/<default>SHA. Skip them only when the previous iteration ended with a green POST-MERGE VERIFY and<remote>/<default>still resolves to that exact verified commit; recordtrunk gate: skipped (verified <sha>). Green -> continue. Red -> STOP and report. -
PICK BATCH. Run CENSUS first, every iteration, and select from it rather than from a bare ready list. The pool is its
readycategory exactly --quarantinedandabandoned-claimare separate categories, so neither can reach the pool and no subtraction is needed here. Take everyabandoned-claimthrough THE RUN LEDGER's RECOVERY before selecting; what recovery returns toopenappears asreadyin the next iteration's census. Then run the owner-decision scan## PREFLIGHTdefers to this point, over the issues this census classified as neitherhuman-gatenorlabel-defect. Skip[epic]containers; close an epic only when all children are closed.No issue in the loop-responsible set -> the backlog is clear. Report it with the census and stop. Loop-responsible work remains but none of it is selectable -> that is not clear either: report which categories hold it and why, and stop. Ready work remains alongside issues the loop cannot act on -> continue; a serialized backlog is still a healthy one, and reporting the rest at the end beats stopping on it.
ANCHOR: highest priority P0->P4, then dependency depth (unblockers first).
ESTIMATE:
bd show <id> --jsonfor the anchor and every candidate sibling; readestimated_minutes. Unset -> derive from the BATCH BUDGET rubric and write it back.ANCHOR SIZING:
- anchor is an owner-decision issue -> the batch is the anchor alone; skip GROW entirely, because OWNER-DECISION ISSUES below requires one PR to carry one decision. The GROW rule only bars an owner-decision sibling, so without this line the anchor's own case is left to the executor and two of them would batch it differently.
- anchor estimate >= TARGET -> the batch is the anchor alone; admit nobody.
- anchor estimate > CEILING -> still run it alone, and record
oversizedon its line and in the final report: that issue wants splitting, not batching. Never skip it, the loop must make progress.
GROW (only when the anchor is under TARGET): take the anchor's parent epic from its
bd show --json, thenbd ready --parent <epic-id> --json --exclude-type=epic, ordered the same way. Admit a sibling only if ALL hold:- same parent epic as the anchor
- same priority band
- not an owner-decision issue
- batch total + its estimate <= CEILING
- resulting member count <= CONCERNS
Stop growing once the batch total reaches TARGET. Otherwise the batch is the anchor alone. Never batch across epics.
bd readyalready excludes a sibling blocked by another unfinished issue.Record
batch estimate: <n> min across <k> issues. -
BASE. Repeat the safe fetch/switch/ff-only update from step 1. Record the resulting
<remote>/<default>commit as<batch-base-sha>. Every batch starts from freshly merged trunk without disturbing pre-existing dirty files. -
CLAIM.
bd show <id>for every member, then atomically claim each withbd update <id> --claimand set its run metadata, includingbacklog_loop_phase=claimedandbacklog_loop_base=<batch-base-sha>. If any claim or metadata write fails, release every member claimed by this attempt back to open, clear its assignee and run metadata, then re-readbd ready; never continue with a partial batch. -
RUN THE PIPELINE, WITH TWO CHECKS AT THE PLAN BOUNDARY.
Compose a brief that lists each member as its own unit,
<id> | <title> | <description> | <design> | <relevant notes> | <acceptance criteria>, and states plainly: one plan with one unit per issue, one branch, one PR covering all members. Include<excluded-paths>as caller-owned WIP that every child must leave untouched. For an owner-decision member, also map the chosen design entry into LFG's settled-decisions brief with decision, provenanceuser-directed(the explicit backlog-loop invocation authorizes its recommended-option rule), rejected alternative, and reason. Useuser-approvedonly when the tracker proves approval. This makes the plan carry one U-ID per issue and preserves tracker decisions instead of flattening the batch into one vague blob.Read the current available LFG
SKILL.md,references/plan-brief.md,references/work-return.md,references/review-followup.md, andreferences/shipping-tail.md; they own the stage contracts below. Act as a headless automated pipeline caller equivalent to LFG: no child may present a user menu or ask a question, and every child returns control to this loop.PIPELINE:
- Invoke
compound-engineering:ce-planwith the composite brief and explicit headless pipeline context. Require its reported plan path, implementation-ready code metadata, confidence receipt, and completed non-interactive doc-review envelope. The single missing-path retry and every blocked/readiness rule come from LFG'splan-brief.md. Do not run a duplicate doc review when that envelope is complete; if it reportsskill_unreachable, invokecompound-engineering:ce-doc-review mode:non-interactive <reported-path>once and require a valid return. - Perform the plan-boundary checks below. When a member is removed, delete its unit and all now-orphaned requirements, acceptance evidence, verification mappings, dependency edges, and Definition-of-Done entries; then re-run
ce-doc-review mode:non-interactiveon the edited plan and require a coherent result. - Read LFG's
work-return.md, then invokecompound-engineering:ce-work mode:return-to-caller <reported-path>. Do not add the unsupportedexclude:carrier; ce-work's workspace setup owns the pre-work WIP inventory and collision gate. Only a validstatus: completereceipt advances. - Read LFG's
review-followup.md; runcompound-engineering:ce-simplify-codewith the plan structure-pin context and explicit caller-owned WIP exclusions unless LFG's docs-only/trivial skip applies. Then run the supported review invocationcompound-engineering:ce-code-review mode:agent base:<batch-base-sha> plan:<reported-path>. Its base scope can still observe tracked pre-existing dirt, so filter the structured return before LFG steps 5-6: a finding whose changed evidence is exclusively in<excluded-paths>is out of scope, must be recorded in Coverage as caller-owned pre-existing WIP, and is neither applied nor handed off as a batch residual. A finding connecting an intended batch path to an excluded path remains in scope and blocks rather than editing the excluded path. Excluded paths are never staged by review-fix commits. Requirestatus: complete; adegraded,blocked,skipped, or malformed return takes the blocked path in step 7 -- a review whose reviewers all failed returns no findings, which is not the same as finding nothing. Then execute that reference's apply and residual-handoff stages before moving on, and reconcile every finding the review returned against either an applied fix or an entry in## Unapplied review findings. Reconciliation is per finding, not per section: the section is composed only from findings that were NOT applied, so a review whose findings were all applied correctly produces none. An absent section is therefore clean when every returned finding carries an applied-fix receipt, and unreconciled only when some returned finding has neither a receipt nor a bullet. - Browser testing is a merge prerequisite, so it runs like every other one: commit the candidate first, then start the application from
<clean-tree>at that commit and invokecompound-engineering:ce-test-browser mode:pipelineagainst it, honoring its gate. Run against the working tree and an excluded uncommitted runtime config - a mock auth path, a stub endpoint - is loaded by the browser, passes, and is absent from the PR. Repeat this after any babysitter head change, exactly as step 4's gates are repeated. - Refresh remote refs and require
git rev-parse <remote>/<default>still equals<batch-base-sha>; if trunk moved during the pipeline, block and STOP without merging so no stale-base result can ship. Commit every remaining batch change first, including review fixes, never staging<excluded-paths>, so the gate runs against exactly what will ship;git status --porcelainmay then list nothing outside<excluded-paths>. Record the branch head as<batch-head-sha>and writebacklog_loop_branch,backlog_loop_head, andbacklog_loop_phase=builtfor every member. The branch name is whatshipping-requestedrecovery searches by, so it has to be in the ledger before the push that creates the PR. Build the final reviewed path set fromgit diff --name-only <batch-base-sha> <batch-head-sha>and subtract<excluded-paths>; if an excluded path appears in that diff it was committed by mistake, so block. Run the applicable local quality gate set through CLEAN-TREE GATE RUN at<batch-head-sha>, selecting conditional gates from that complete path set rather than an earlier child receipt. While<batch-ci>=offthis set must also cover every workflow command found by preflight's gate-coverage read, re-resolved against this batch's own tree. - Write
backlog_loop_phase=shipping-requestedfor every member BEFORE invoking the child, because the push and the PR it creates are irreversible and the ledger must already know they might exist. Read LFG'sshipping-tail.md, then invokecompound-engineering:ce-commit-push-pr mode:pipeline branding:on babysit:off exclude:<excluded-paths>when exclusions exist, otherwise omit theexclude:carrier. Thread the plan path, settled-decision conflicts, and residual section required by that reference. Require a pushed branch and open PR URL. Then require the PR to be the one this loop intended:gh pr view <url> --json baseRefName,headRefOid,urlmust reportbaseRefNameequal to<default>,headRefOidequal to<batch-head-sha>, and a URL under the repository<remote>resolves to. A PR opened against another base merges nothing into trunk while every base-and-head guard below still passes. Mismatch -> blocked path. Only then writebacklog_loop_prandbacklog_loop_phase=pr-openfor every member. <batch-ci>=on: record the PR head, invokecompound-engineering:ce-babysit-pr mode:pipeline <pr-url>, and require its current structured CI-decided success contract. That contract authorizes the babysitter to commit and push fixes, and every gate above already ran, so re-read the PR head afterwards. Moved -> the new head has passed CI but has been through no code review, no browser test, and no local gate; return to pipeline step 4 for that head and repeat, at most twice, then block. Unchanged -> continue.<batch-ci>=off: do not invoke the babysitter; the green clean-tree pre-PR gate is the batch authority.
Both checks below happen at the plan gate - after planning has written and reported a plan path, and before implementation reads that plan. Neither is a separate later step; once implementation starts it is too late for either.
-
If no plan path was reported, its own gate has failed: treat the run as blocked and go to step 7's blocked path. Never hunt the filesystem for a plan it did not report.
-
DOC REVIEW. Require the non-interactive document-review state returned by pipeline step 1 and fold its Apply-routed findings into the plan. Do not review the same unchanged plan twice.
-
RE-ESTIMATE AGAINST THE BUDGET (batches of 2+ only). The batch was a hypothesis built from tracker metadata; the plan is the first real evidence about scope and code surface. Re-estimate each unit from what the plan says it actually touches.
- Total > CEILING, or units touch disjoint code surfaces -> drop members from the tail (lowest priority, then deepest) until the batch is back under TARGET and the remainder shares a surface. Never drop the anchor.
- Total still far under TARGET -> do NOT go back and grow; a second grow round costs a second plan, which is what batching exists to avoid. Record the miss.
Dropping a member requires a referentially complete plan edit: remove that unit and every artifact that exists only for it, or the implementation step may still build it and the drop is fiction. Then release its tracker claim so it returns to
bd readywithbd update <id> --status=open --assignee="" --unset-metadata backlog_loop_run; write the re-estimate back and record<id> | returned to pool | re-estimated <old> -> <new> min. A dropped member is NOT counted as attempted.
Continue the pipeline after the boundary checks. A batch advances to merge only with valid stage receipts, an open PR URL, zero canonical
needs-humanresiduals, and zero unchecked entries in## Unapplied review findings-- with every finding the review returned reconciled to either an applied-fix receipt or one of those entries, so an absent section is proof of nothing on its own. Unchecked entries are undecided findings and cannot cross an autonomous merge gate.<batch-ci>=onadditionally requires the babysitter's mergeable CI decision.<batch-ci>=offrequires green applicable local gates on the final diff. Any blocked, failed, malformed, red-required-check, missing-PR, or decision-needed result takes the blocked path in step 7. - Invoke
-
MERGE AFTER THE SELECTED PIPELINE GATE. With
<batch-ci>=on, the babysitter already decided CI; do not add another watcher. With<batch-ci>=off, the final local pre-PR gate is the authority. Immediately before merge, refresh remote refs and require<remote>/<default>still equals<batch-base-sha>, and require the PR's current head to still equal<batch-head-sha>; if either moved after the gate, retire the PR as described below and then take the merge-blocked path. Checking the base alone leaves the reviewed head unpinned, and anyone pushing to the PR branch in that window would have their unreviewed commit merged -- and leaving that PR open afterwards just moves the same merge one human click away. Do not use GitHub auto-merge. Before issuing the merge, setbacklog_loop_phase=merge-requestedon every member: an interruption between the merge and the close is otherwise indistinguishable from an interruption before it, and THE RUN LEDGER's recovery reads exactly this phase to decide whether to finish the close or return the issue to the pool. Then rungh pr merge <url> --squash --delete-branch --match-head-commit <batch-head-sha>, exactly one merge per batch. Never pass--admin.The command's exit status decides nothing. A zero exit is a request, not a merge: on a merge-queue branch
gh pr mergeenables auto-merge or enqueues the PR and still returns success. A NON-zero exit is not a failure either: GitHub can complete the squash merge and the connection drop before the response arrives. So pollgh pr view <url> --json state,mergeCommitevery 30s under a bounded 10-minute deadline after EVERY merge invocation, whatever it returned.MERGED-> record its merge commit as<merge-sha>, then requiregit merge-base --is-ancestor <merge-sha> <remote>/<default>after a fetch, so the commit is proven to be on the branch this loop was working against and not merely merged somewhere. Writebacklog_loop_mergeandbacklog_loop_phase=mergedfor every member.Anything else -> the merge did not happen and the PR must not be left mergeable by someone else. Run
gh pr merge <url> --disable-autoif auto-merge ended up armed, then `gh pr close --comment "not
Truncated - read the full file at https://github.com/vinhnxv/skills/blob/aebcfc3b86e038193efdbfc95fc685c762442894/skills/codex/backlog-loop/SKILL.md.