Imported from chalbert/web-everything (
skills-src/drain/SKILL.md). Install upstream withnpx skills add chalbert/web-everything --skill drain. Copyright stays with the author.
Drain the deferred merge queue (#2162)
A resident daemon normally owns this now (#2449). The phase-1 drain daemon (
plateau:tools/drain-daemon/, hosted in plateau-app next to the dev-panel) holds the whole-process drain lease (#2391) for its entire residency and runs the sweep below as one-shot passes on an interval — who runs/drainis the one session convention it retires. The lease is now ALWAYS-ON for full/label sweeps and watches (#2449 closed #2424's opt-in gap and ratified #2443's "hold by default"): while the daemon (or any other drain) holds it, a manual full/drain//mergeno-ops exit 0 surfacing the holder — correct behavior, not a failure; the resident's next pass covers the work.--only=<pr>fast drains (what/pr//finishshell) bypass the lease and stay instant (the numbering mutex serializes lands);--dry-runalso bypasses, so the plan read below always works. Daemon stopped/absent ⇒ everything degrades to exactly this skill's manual flow — it remains the fallback and the single source for how a sweep works. Operate the daemon from the plateau-app checkout:node tools/drain-daemon/cli.mjs status|once|logs|install|stop.
The whole mechanism lives in scripts/merge-ai-prs.mjs — this skill is
the trigger + the ceremony around one invocation, so there is nothing to keep in sync here. The drain is the
consumer side of the #2183 PR fan-out: producing sessions (parallel /workflow lanes, solo #2123 lanes, and
batch closeout) each open a ready-to-merge PR per item (#2196); this drain lands those labelled PRs
serially, in a later session, under the same self-approved PR transport the producers use.
Converged label lander (#2188/#2194).
/drainis the label landerscripts/merge-ai-prs.mjs --label=ready-to-merge— bare = one cascade pass,--watch= the poll loop. It merges the openready-to-mergePRs (incl. orphans opened directly by/pr) in cross-itemblockedByorder (each PR's.lane-manifest.json, read off its head ref, supplies the edges), and the PR-merge IS the single clear point (noqueued.jsonunqueue)./merge(bare) is the same lander without the label scope. The oldqueued.json/scripts/lane-drain.mjscouple-drain is retired to a legacy no-op fallback (see below) — new producer output lands via the label lander.
Two-form ids — the drain numbers hashes at land (#2288). A queued item's files may lead with EITHER a landed numeric
NNNor a provisionalxNNNNNNhash (born hash-keyed so parallel lanes never race onmax+1). The drain is the sole serial writer to main, so it assigns the real sequentialNNNat land (numberPendingHashes, blind-rewriting the hash → number across the filename, the item's frontmatter/body, and every other item's edges) — you never hand-assign one. Both the primary lander (merge-ai-prs.mjs) and thepr-land --fallback-gitroute number, so no land path strands a hash on main (#xzxc92d).
Preconditions
-
Run from a LEASED lane-pool clone — NEVER the shared primary checkout (#2197 / ratified #2123). The drain is an edit-action session (it advances
mainand, per #2198, rebuilds lane tips), so #2123's "every edit-action session runs in an isolated clone" applies. The primary tree almost always carries a peer session's uncommitted work, and the drain's own post-mergegit pull --ff-only --autostashthen hits an autostash-pop conflict and strands the tree mid-merge (observed 2026-07-03: an autostash-pop conflict onclaims.jsonleft the primary half-merged; recovery was manual). Acquire a lease on the general lane-pool allocator (#2275/#2303) instead of hand-rolling a clone — the drain is just another consumer of the same primitive/batch/solo#2123lanes use:node scripts/lane-pool.mjs acquire --purpose=drain --session=<drain-session-slug> --jsonThis claims an exclusive
.git/.lane-leasemarker on the lowest free pool lane (or--lane=N), thenreset --hards it toorigin/<branch>and ensures itsnode_modules(npm ci, real per-lane deps — no primary symlink) — exactly the "land on truemainwith deps present" state the old hand-rolled recipe manually constructed, but held so no concurrentrefresh/provision/acquirecan yank it mid-cascade. The JSON reply is{ lane, path, session, purpose, branch }—cdinto.pathand work there.cdback to.path's repo root before every command below; do not run them from the primary. When the drain is done (a one-shot pass finishes, or a--watchloop exits/is stopped), hand the lane back:node scripts/lane-pool.mjs release --lane=<lane> --session=<drain-session-slug>--session(or theLANE_SESSIONenv) ties oneacquireto its matchingrelease— reuse the SAME slug for both calls. A--watchdrain acquires ONCE at start and releases ONCE at exit, not per pass. If a release is skipped (a killed--watch), the lease self-reclaims after its TTL (DEFAULT_LEASE_TTL_MINUTES= 240) — a crashed drain returns its lane to the pool the same day, not never. The checkout root is allocator config (LANE_POOL_ROOTenv, else<the checkout's workspace>/.lanes, derived fromgit rev-parse --show-toplevelrather than$HOMEsince #3265) — no skill embeds a literal../we-drain-cleanor.lanespath.The pool already carries the render sibling every WE checkout needs:
../frontieruiat the pool root (scripts/lane-pool.mjsensureFuiSibling, #2166), resolvable from a leased lane exactly as from a provisioned one — no extra step.../plateau-apphas no pool-root sibling yet (tracked separately, #2349 — generalizing the FUI symlink into a per-repo pushable+built clone for bothfrontieruiandplateau-app); until that lands,--all-repos' rebase-drop (#2198) degrades exactly as it did before #2263 for any repo with no sibling present —left for its author, never an error. The branch guard blockscheckout -B/worktree addeven in a lane, soacquire's ownreset --hardis how the lane lands onmain— never a manual checkout. Nevergit pullin the primary — all of this runs in the leased lane only. -
ghis authenticated (gh auth status) — landing is the same self-approvedgh pr merge(0 required reviewers + the requiredtestcheck)/pruses. See/prfor the transport. -
The lane's tree is clean on acquire (a fresh
reset --hard+clean -fd): the post-merge sync (git pull --ff-only --autostash, in the lane) is a pure fast-forward there, so it never conflicts with a peer session's edits the way the primary would.
Run it (the label lander — #2194)
/drain now drives the ONE label lander scripts/merge-ai-prs.mjs --label=ready-to-merge, not
lane-drain.mjs (the constellation sweep is the default since #2287 — no --all-repos needed; pass
--this-repo to scope to the cwd repo only). It sweeps the open ready-to-merge PRs and merges each as it becomes eligible (green +
mergeable), in cross-item blockedBy order (each PR's .lane-manifest.json, read off its head ref, supplies
the edges — the #2188 convergence).
One skill, all 3 repos — BY DEFAULT (#2257/#2287). The single lander sweeps every constellation repo — web-everything + frontierui + plateau-app — in ONE global
blockedBycascade, with no flag needed (#2287 made all-repos the default). This is why/drainstays a single skill instead of a copy per repo (#2244/#2245 superseded): the backlog is WE-global, so a frontierui PR can beblockedBya WE item, and only a single cross-repo sequencer can order that. Everyghcall is--repo-scoped; a remote-repo PR reads its manifest via the GitHub API (never a local clone). The rebase-drop (#2198) still needs local git plumbing (merge-tree/commit-tree/push): for the LOCAL clone's own repo it runs in place; for a remote repo it routes through that repo's sibling clone (../frontierui,../plateau-app) when the leased lane's pool root carries one (#2263; the pool-root../frontieruisymlink, #2166 — see the precondition above) — so a CONFLICTING/BEHIND non-local lane tip gets rebuilt too, not just left for its author. No sibling clone provisioned ⇒ unchanged legacy skip. Landing a frontierui/plateau PR still needs that repo's own requiredtestcheck + branch protection (#2242/#2243/#2246) or GitHub blocks the merge; until those land, those PRs surface here asskip (required check "test" is not green)rather than silently vanishing. Pass--this-repoto scope to the cwd repo only, or--repos=owner/a,owner/bfor an explicit set. (--all-reposis still accepted — it's a no-op alias of the default now.)
node scripts/merge-ai-prs.mjs --label=ready-to-merge --dry-run # plan only — print the blockedBy-ordered merge order (across ALL 3 repos, the default) + deferred set, merge NOTHING
node scripts/merge-ai-prs.mjs --label=ready-to-merge --primary=<primary> # /drain (bare): ONE cascade pass across all 3 repos — land every ready labelled PR, exit
node scripts/merge-ai-prs.mjs --label=ready-to-merge --primary=<primary> --watch --interval=30 # /drain watch: keep polling; land each PR the instant it goes green (--max-idle=N bounds it; Ctrl-C stops)
node scripts/merge-ai-prs.mjs --label=ready-to-merge --primary=<primary> --watch --until-batches-idle # /drain watch that SELF-TERMINATES when the active batch is fully delivered (#2330)
node scripts/merge-ai-prs.mjs --label=ready-to-merge --this-repo # opt OUT: scope to the cwd repo only (a deliberately single-repo drain)
Always dry-run first to show the merge plan, then run bare (one-shot) or --watch (follow). Prefer the
one-shot unless the user wants a long-lived monitor waiting for producers still opening PRs.
--until-batches-idle — a batch-aware exit for a drain launched to land a batch (#2330). --max-idle=N is
UNSAFE for a live batch: items take minutes, so the watch goes idle between PRs and --max-idle would exit
mid-batch. --until-batches-idle instead exits only when the safe conjunction holds — no kind:batch status:running run remains AND the ready-to-merge queue is empty AND nothing is deferred — debounced
over --batch-idle-debounce passes (default 2). It reads the running-batch signal from the active-progress feed
(_site/active-progress.json, written by scripts/dev/active-progress-watch.mjs); an absent/stale feed ⇒
keep watching, never a false stop. The feed only exists while that dev watcher runs, so for a drain-only
session point --batch-feed=<path> at the primary checkout's copy (else the drain harmlessly runs unbounded —
it now prints a one-time note when the feed is absent so the inert degrade is visible). Before honoring the
exit the drain re-polls once to confirm the ready-to-merge queue is genuinely empty (the last PR's label
can lag the producer's resolve — the #2230 defense, so the final PR is never dropped). Keep
--batch-feed-stale-sec (default 30s) comfortably above the watcher's ~4s write cadence.
(Design note carried from the #2330 review: the feed is a dev-only, website-facing artifact — a later
refinement should read the batch journals directly to drop that coupling; the exit contract above is unchanged.)
Pass
--primary=<primary>so the post-land sync can find your primary checkout (#xwokc1n). After each land the drain fast-forwards the user's primary checkout to the advancedorigin/mainso it never rots. It locates that primary via--primary=<path>(or theWE_PRIMARYenv), falling back to the clone's git alternates. A leased lane-pool clone (#2303) ISgit clone --reference-based, so it carries an alternates file pointing at the pool's reference checkout (normally the primary) — the fallback now usually resolves it automatically, unlike the old hand-rolledgit clone --localrecipe (which created no alternates file, so without--primary/WE_PRIMARYthe primary silently drifted — observed 75 commits behind). Still pass--primary/WE_PRIMARYexplicitly when you want it guaranteed (e.g. the pool was provisioned with a non-primary--reference). The sync is a puregit pull --ff-onlyand only touches a primary that is onmainwith a clean tree — a dirty primary (a peer session's uncommitted work) is left UNTOUCHED and logged, never autostashed or stranded (the 2026-07-03 incident). Omit the flag deliberately if you do NOT want your primary advanced.
How it works (per pass)
- Lists the open PRs carrying the
ready-to-mergelabel (gh pr list --label ready-to-merge) — every producer (/workflow,/pr, solo#2123lanes, batch closeout) applies it (#2196), so this is the single collection point for ALL AI-generated work. - For each candidate, reads its
.lane-manifest.jsonoff its head ref and orders by cross-itemblockedBy— a PR whose blocker is still an open (unlanded) PR defers to a later pass (the cascade). Orphan PRs (no manifest) are always ready. - Rebase-drops the shared manifest (#2198) — a certified + green PR that is only CONFLICTING/BEHIND on the
one shared
.lane-manifest.jsonpath is rebuilt ontomain(manifest dropped) via pure plumbing (no checkout) before merging, so the "manifest lands then conflicts every other PR" wall no longer stalls the queue. A real (non-manifest) code conflict is left as a skip for a human. On by default;--no-rebase-dropdisables. A rebuilt tip re-runstest, so it lands on a later--watchpass (expected, not a failure). - Merges each ready PR via the SAME self-approved, non-admin
gh pr merge --merge --delete-branchthe/prflow uses — only when its requiredtestcheck is green and GitHub reports it cleanly mergeable. A merge frees its dependents the next inner pass; a failed/behind PR stays blocking its dependents (never land past a broken blocker). The closed PR carries the label away — that PR-merge IS the single clear point (noqueued.jsonunqueue). - After anything merged, fast-forwards the clone's local
mainto the advancedorigin/main(git pull --ff-only --autostash, best-effort) — in the isolated clone, never the primary checkout.
Report the pass to the operator via renderDrainRunSummary() (#2433), not hand-composed prose. Feed it the
(sub)shape of the pass's own --json result ({merged, failed, deferred, parked, skipped, dryRun} — the exact
fields merge-ai-prs.mjs already returns, see ## Run it above) and post its output verbatim as the end-of-run
line(s) — this is the ONE place that wording is templated, so a plain one-shot pass, a --watch loop's final
pass, and a batch-closeout drain all report the same way.
Stacked-chain couples — the proof-of-land gate (#2387)
A serial /batch may hand this drain an overlap-stacked chain: item N+1's lane was cut from item N's
pushed tip because they share files, so their PRs must land in order. The transport above is
unchanged — the ordering is enforced by a positive, identity-based proof-of-land gate read off each
PR's manifest, never by ref-absence:
- A stacked PR's
.lane-manifest.jsoncarriesstackParents(anasItemIdlist — the frontier-tip item(s) its lane was cut from / merged onto) plus a per-repobaseSHA.blockedBystays the hard semantic edge;stackParentsis the lineage edge. - Gate a couple READY only when every
stackParentis proven landed — either landed this same pass (the in-memory set) orbornAs-proven onmain. A parent that is neither is a DEFER, not a skip. Never read a missing ref as "landed" — this is a positive gate by construction (the #2387 F5 stowaway defense: salvaging a tail past an unlanded parent would drag the parent's unreviewed code ontomainunder the child's number). bornAs: <hash>is the landed proof. At land the numbering routine stampsbornAs: <birth-hash>on the item's frontmatter and excludes it from the blind hash→NNN rewrite (the one-line guard in we:scripts/backlog/id.mjs) — so it survives onmainas the durable, cross-clone, renumber-immune birth record every clone reads;landedNumberFor(hash)resolves it. (Not excluding it is a permanent-strand deadlock — the parent's proof would be rewritten to a number the child's recorded parent never matches.)bornAs-on-mainis the sole cross-clone landed proof; the localid-ledger.jsonis numbering bookkeeping only, andbornAsderives one-directionally from it so they cannot diverge.- Couple-granular. The gate is evaluated at couple granularity via the impl-PR→WE-manifest
laneRefjoin, so a manifest-less impl PR inherits its couple'sstackParentsand is never independently "ready" ahead of its couple.
Because a landed parent is an ancestor of main, git auto-computes it as the merge-base and the stacked
child lands three-way-clean through the unchanged rebase-drop rebuild (#2198) — no --merge-base
override. A broken parent (red test / bounced review:changes) defers its true overlap-descendants
while disjoint siblings land normally; the tail is rebuilt by /finish's stack-repair.
Full end-to-end narrative — overlap-stacking, the actual ⊆ declared producer contract, the capability-marker
rollout, push-at-close: docs/agent/backlog-workflow.md → Overlap-stacked serial batches.
Two locks (#2391), not one. The drain holds a whole-process lease (O_EXCL + TTL) for its lifetime
(the daemon note at the top of this skill) and a distinct fine numbering-critical-section mutex around
number + publish at every land call site. They are separate on purpose: the lease delivers "exactly one
drain," the mutex enforces "sole serial writer to main" (no two lands race to max+1 and mint a duplicate
NNN). Push-at-close checks the lease.
Auto-review the parked PRs (#2285 v1 + v2)
The #2171/#2262 review-escalation gate parks a blast-radius PR (review:pending) and waits for an
independent reviewer to apply review:accepted/review:changes — otherwise every escalated PR stalls the
queue until a human gets to it. v1 makes the drain run that independent review itself, in a spawned
reviewer — for the PRs where an agent reviewer is genuinely independent of the producer. v2 (#2311)
replaces v1's author-bounce with a bounded editor↔reviewer negotiation loop (below). The one invariant BOTH
preserve: a landed PR was accepted by an agent that did not author it.
One engine (#2326). The
{findings, verdict}contract is single-sourced inwe:scripts/lib/review-core.mjs(#2325):buildMandate()renders the judge-only instruction each juror is seeded with (it bakes in the #2336 diff-only, no-checkout clause),normalizeFindings()shapes what it returns, andderiveVerdict()maps findings →accept/changes/needs-human. The drain review below and the/reviewhuman-verdict skill BOTH render through it — no hand-rolled reviewer prose. #2311 (v2) addsbuildEditorMandate()(seeds the editor subagent's revision round) andderiveNegotiationOutcome()(the ONE deterministiccontinue/land/escalatederivation from a round's verdict + the round cap) — same module, same single-sourcing discipline. #2310 (v3) adds theMANDATE_LENSES/MANDATORY_LENSES/ADVISORY_LENSESpanel,buildPanelMandate()(seeds one reviewer per lens),derivePanelVerdict()(reduces the panel's per-lens verdicts to the ONE combined verdictderiveNegotiationOutcomealready consumes — the round loop itself is unchanged) andrenderPanelVerdictTable()(the operator-facing split-verdict surface). #2433 adds the SESSION/NOTICE renderers (distinct from the PR-comment table above):renderDrainRunSummary()(the end-of-run pass summary) andrenderReviewNotice()(the in-chat escalation/clearance notice — used by both this skill and/review) — same module, same discipline: template the render, never hand-type the prose.
The lander classifies each parked PR (see we:scripts/lib/review-escalation.mjs isDeclarativeLeashPath) and
emits it in the --json output's parked array as { num, repo, humanRequired, reasons }.
humanRequiredis NARROWER than "touches the gate" (#2771/#2785, statute#review-human-declarative-leash-only). Only the declarative leash — the policy contract, thewe:gate-config.mjsroster, and the invariant / conformance suites — forces a human. The gate's derivation code (we:review-escalation.mjs,we:review-core.mjs,we:review-policy.mjs, the two land seams) parksreview:pendingand the independent committee clears it, exactly like the engine tier. So do NOT tell the operator a PR is theirs merely because it edits the gate — readhumanRequired/ thegate-selfvsgate-derivationreason, never the file path.
The converge-vs-human branch is ONE derivation (#2285). Don't hand-branch on
humanRequired— callderiveReviewDisposition({ reasons })inwe:scripts/lib/review-core.mjsand act on{ mode, autoLand }. It is single-sourced so every review surface (this drain,/review,/merge) shares the policy, keyed on WHY the PR escalated.mode: 'converge'→ run the panel↔editor loop below;mode: 'human'→ hand straight to a human, no convergence.autoLand: false→ an agent may FIX but never CLEAR it (a human gates the merge). Map the drain's signals to reasons: don't re-derive them — theparkedentry'sreasonsalready carry the exact decorated strings (gate-self/gate-derivation/statute/ blast-radius / size / dismissed-findings / cross-repo), and a negotiation that hits the round cap or a mandate conflict (below) addsnon-convergence/mandate-conflict. Pass them through verbatim.
No park ever times out (x30jq9n, resolving #2412 Gap 1). The old 30-minute merge-anyway window (
--review-window-minutes, #2262) is REMOVED: it raced the very review it was waiting for — observed 2026-07-10, PR #396 merged mid-negotiation at its round-1 head while mandatory-lens fixes were still being written. A parked PR now rests parked until a verdict label (review:accepted/review:changes) arrives. A genuinely stuck park is the OPERATOR's call: run a manual/drain, then use the per-PR relief valve (#2423) to push just the stuck PR through.PREFERRED — the per-PR form
--no-review-escalation=<pr#>(repeatable, comma-separated:--no-review-escalation=396,401). It waives ONLY the named PR's agent-reviewablereview:pendingpark to a merge; the escalation rubric stays LIVE for every other candidate in the pass, so a fresh PR with noreview:*label yet — even a gate-self diff — is still scored and parked normally. The named PR is scored too: the override still refusesreview:human(a gate-self/statute edit — human-only, #2285) andreview:changes(the reviewer actively rejected the diff). This is the relief valve to reach for.DEPRECATED — the bare, PASS-WIDE form
--no-review-escalation(no=<pr#>). It turns the escalation rubric off for EVERY candidate in that pass, so a fresh PR with noreview:*label yet (even a gate-self diff) would merge unscored — a strictly wider unreviewed-merge window than the per-PR form. It still works and still refusesreview:human/review:changes, but the drain now prints a loud deprecation warning pointing at the per-PR form. If you must use it, scope the pass (--this-repo, a narrow--label) and confirm with--dry-runthat the plan is only the stuck PR first. Landing unreviewed code is never the drain's failure mode.
-
gate-self(humanRequired: true) →{ mode: converge, autoLand: false }. The diff edits the auto-review trust chain itself (we:scripts/lib/review-escalation.mjsorwe:scripts/merge-ai-prs.mjs) — the code that decides whether the gate fires and what clears it. This is a sensitivity park, not a deadlock, so an agent reviewer/editor is still useful: run the SAME panel↔editor negotiation loop as the agent-reviewable path below to actually fix any clear bug (an advisory FIX, not just an advisory comment). The one difference isautoLand: false— on alandoutcome the drain does NOT applyreview:accepted; it keepsreview:human, posts the converged diff's findings + verdict table as a PR comment clearly marked🤖 advisory AI review / fix (non-clearing)and flags that the diff now carries agent-authored trust-chain edits the human must scrutinize, and surfaces the PR to the operator, who clears it with/review <PR>. This PR is never agent-clearable — an agent policing a change to its own leash is the conflict of interest the gate exists for; the panel may improve the diff but only a human merges it (the #2285 invariant, enforced byautoLand: false).Single PAT ⇒ you cannot verify a gate-self clearance by actor (#2416). The whole constellation runs on ONE personal access token, so EVERY
review:acceptedlabel +"cleared by the operator"comment is applied aschalbertwhether a human ran/reviewor an automation (a closing-session / batch flow) did. The GitHub actor therefore proves nothing — this is exactly the #2416 gap ("honorreview:acceptedonly when a human applied it"). The closed-set-of-callers guarantee now lives inwe:scripts/lib/review-skill-guard.mjs'scheck:standardsgate (doc instructions, #2882; raw code paths, #2416) — it is NOT actor provenance, which stays permanently unsolvable under one shared token. Operational rule when draining: if agate-selfPR arrives already carryingreview:accepted, do NOT treat the label/comment as proof of human clearance — surface it and get the operator to confirm they personally cleared it before letting it land. Do not, however, treat "shows aschalbert" as suspicious on its own — it is the only actor there is. -
sensitivity park (
humanRequired: false) →{ mode: converge, autoLand: true }— agent-reviewable. Escalated (blast-radius / size / dismissed-findings / sampling) but independent of the producer. v3 (#2310) runs a bounded MULTI-MANDATE PANEL↔editor NEGOTIATION LOOP — v2's single reviewer fans out into a panel of distinct mandated reviewers (PANEL_LENSES:correctness/security/simplicity/standards-conformance, the/code-reviewlenses), driven up toNEGOTIATION_ROUND_CAP(5) rounds of propose → critique → revise, in-session, before escalating:-
Round 1 panel review. Get the diff on the NET basis vs CURRENT main —
computeNetDiffText({ exec, rev: v.headRef, fetchExtraRefs: [v.headRef] })(we:scripts/merge-ai-prs.mjs, #2450): the two-treegit diff <forkpoint> <head>TEXT resolved off the SAME #2373/#2404 basis the escalation SCORE uses, so the diff the panel reviews and the score can't drift. This is notgh pr diff <num>'s three-dot merge-base diff, which still lists a sibling-lane file that has since landed on main as if this PR added it — the phantom scope-creep that burns rounds (#2450). If it returnsscored:false(a foreign clone without the head ref, a diff failure), fall back togh pr diff <num> --repo <repo>. Alsogh pr view <num> --repo <repo> --json title,body,files, and take the NET changed-file list fromcomputeNetDiffPaths(...)— plain paths off the same basis. NOTcomputeNetDiffChangedFiles, which is the scoring path and emits git's display encoding (a.txt => b.txtfor a rename, C-quoted non-ASCII), so intersecting it withgh's plain paths drops those entries and a rename-only PR reads as zero files. Fan the panel out throughjudgePanel, NOT theAgenttool (#3145) — a subagent inherits this session'sCLAUDE_CODE_SESSION_ID, the identitywe:scripts/lib/review-independence.mjskeys independence on, so a panel of subagents is ONE ACTOR WEARING N HATS and the independent reviewer this whole section claims does not exist. Build ONE payload — one juror per lens, each carryingbuildPanelMandate({ lens, netChangedFiles, goal, round })verbatim (the net changed-file list is passed as GROUND TRUTH so a reviewer will NOT flag a diff-side file outside that set as scope creep, #2450; same diff-only, no-checkout isolation as v2 (#2336), each juror judging only its own lens and blind to the others) — then shell the panel shim, which spawns one tool-free headlessclaude -pper seat with its own derived--session-idand refuses a roster whose ids are not pairwise distinct before anything runs:# $DIFF ← computeNetDiffText's TEXT on disk; $NET ← computeNetDiffPaths as a JSON array on disk. # Write the payload with node, NEVER by interpolating the diff into a shell string — a diff routinely # contains `$(…)` and backticks, which the shell would evaluate before Node ever saw the JSON. node --input-type=module -e ' import { writeFileSync, readFileSync } from "node:fs"; import { PANEL_LENSES, buildPanelMandate } from "./scripts/lib/review-core.mjs"; const round = Number(process.env.ROUND); writeFileSync(process.env.PAYLOAD, JSON.stringify({ subject: "pr-diff", subjectNoun: "diff", round, materialFile: process.env.DIFF, jurors: PANEL_LENSES.map((lens) => ({ id: `${lens}#1`, lens, mandate: buildPanelMandate({ lens, round, goal: process.env.GOAL ?? "", netChangedFiles: JSON.parse(readFileSync(process.env.NET, "utf8")), }), })), }));' node skills-src/jury/panel-fanout.mjs --payload-file="$PAYLOAD" \ --depth=0 --max-depth=2 --max-total-budget-usd=8 --run-id="drain-<pr>-r<round>"Each seat comes back as
{ id, lens, sessionId, ok, findings, notes, error, costUsd }. A seat withok: falseis a lens that DID NOT RUN — it never reads as accept, and a mandatory lens that did not run degrades the round (same rule as everywhere else). Shape each seat's findings withnormalizeFindings(), reduce each to its own verdict withderiveVerdict()— you now have one{ lens: verdict }map (lensVerdicts) and, viabuildPanelFindings(), one lens-tagged findings list.What a tool-free juror cannot do — stated, not buried.
judgePanelseats are always--tools ''(it forwards noallowedTools— RULING inwe:scripts/lib/judge-panel.mjs, #3158: panel seats stay tool-free rather than pay the N-seats-need-N-lanes cost of a tool-bearing panel). A tool-free juror cannot literally break a line or clone the repo, soMUTATION_PROBE_RULEand the mandate's isolation clause now SELF-SCOPE on that fact (same pattern as the mutation probe's own lens-scoping): a tool-free seat is told to name the test it BELIEVES would redden and say plainly it ran nothing, never to describe a break or a clone it did not perform.panel-fanoutseparately tells each juror it has no tools and must not claim to have opened anything, so a seat reports honestly rather than fabricating — it fails safe. The panel is still weaker at exactly the class of finding a real mutation run catches; that is a trade this change made deliberately in exchange for jurors that are actually distinct actors, now stated honestly to the juror itself rather than left as an unrunnable instruction. Ruled in #3158. -
Reduce the panel to one verdict —
derivePanelVerdict({ lensVerdicts, humanRequired, conflict, mandatoryLenses, findings: buildPanelFindings(lensFindings) }). Passfindings— the whole panel's list from step 1 — always (#2823 round-3 finding 1): the prevention scan is derived from the FINDINGS (immune to per-lens verdict flattening), so dropping the list silently reinstates the advisory-prevention leak on the ONE path the drain actually runs.findingsis a REQUIRED argument — an omitting call now throws rather than defaulting to[].MANDATORY_LENSES(correctness,security— real invariants with no other gate) must unanimously accept to land;ADVISORY_LENSES(simplicity,standards-conformance,claim-accuracy—standards-conformancealready has a deterministic backstop incheck:standards, #2199;simplicityis genuine stylistic judgment;claim-accuracyis advisory PENDING ITS OWN RULING, #3035, not on merit) are always surfaced but never block on their own. (This line read "(simplicity,standards-conformance)" until #3035 added the third member — it was a two-member list stating a set that had grown to three.)conflictis a judgment call, not a mechanical one (#51): read the mandatory lenses' findings — if they are a genuine MUTUALLY-EXCLUSIVE tradeoff (e.g. security's fix directly undoes simplicity's, or vice versa within the mandatory pair), passconflict: true; a merely-unlucky pair of independent "changes" verdicts is not a conflict, it is ordinary non-convergence and follows the round-cap path below. -
Decide what's next —
deriveNegotiationOutcome({ verdict, round, roundCap })on the REDUCED panel verdict from step 2 (pure; the round-cap decision is deterministic, not a judgment call):-
land(verdictaccept— every mandatory lens unanimously accepted) → gate on the disposition'sautoLand.autoLand: true(a plain sensitivity park, incl. the engine-tier lander) → run the INDEPENDENT HARDENED VALIDATOR (#2439) before landing. Run the validator JURY through the SAMEjudgePanelshim as step 1 — never theAgenttool (#3145). This is the site where the subagent fan-out was most wrong: the jury's entire claim is that it "took NO part in the negotiation", and a subagent jury shares the negotiating session'sCLAUDE_CODE_SESSION_ID, so by this repo's own independence test it took part in all of it. Same payload shape, one juror perPANEL_LENSESlens, each seeded withbuildValidatorMandate({ lens })instead ofbuildPanelMandate(...), andmaterialFilepointed at the FINAL diff. Give it a DIFFERENT--run-idfrom every negotiation round (e.g.drain-<pr>-validator): a seat's session id is derived fromrunId+lens#slot, so reusing the round's run id would mint the validator the very same actor id as the panel juror it is supposed to be independent of. The jury is NEVER shown the peers' findings, dismissals, or reasoning — only the FINAL diff and the tests it touches, which is exactly what a fresh payload carrying onlybuildValidatorMandate+ the diff gives it. Reduce its per-lens verdicts withderivePanelVerdict({ lensVerdicts: validatorVerdicts, mandatoryLenses, findings: buildPanelFindings(validatorFindings) })→validatorVerdict— again passing the validator jury's OWNfindings(required, same reason as step 2: the validator can re-report a resolved finding that still names an uncaptured guard, which only the findings scan sees) — thencombineValidatedVerdict({ panelVerdict, validatorVerdict })and re-runderiveNegotiationOutcomeon the combined verdict: combinedland(BOTH the panel and the independent validator accepted) → first run the BAR-UN-BLOCKED PREVENTION CHECK, THEN applyredteam:acceptedTHENreview:accepted(gh pr edit <num> --repo <repo> --add-label redteam:accepted --add-label review:accepted) and re-run the drain — the non-author-accepts invariant now holds INDEPENDENTLY; combinedcontinue(the validator wants changes the panel missed) → step 4, another editor round; combinedescalate(validatorneeds-human) → theescalatepath below.autoLand: false(gate-self) → do NOT applyreview:accepted: the panel converged and fixed the diff, but a human must clear a trust-chain edit. Keepreview:human, post the converged findings +renderPanelVerdictTable(...)as the🤖 advisory AI review / fix (non-clearing)comment, and surface the PR to the operator viarenderReviewNotice({ event: 'escalated', pr, repo, verdict, disposition, reasons, findings })(#2433) — passfindings = buildPanelFindings(lensFindings)(the whole panel's list) so the notice's #2823 prevention summary renders the guards owed before accept — rather than hand-typing the in-chat notice; the fix rode the PR branch, the clearance did not.THE BAR-UN-BLOCKED PREVENTION CHECK — the compensating control for
PREVENTION_IMPACT_BAR. The impact bar (we:scripts/lib/jury-core.mjs) lets a finding whose named prevention guard is neither captured nor filed ride a CLEANaccept, as long as the reviewer declared the finding cheap (impactIfUnfixedbelow the bar). That is a scaling of the gate rather than a silent loosening ONLY if the declared impact and the still-owed guard reach a human on the path it opens — and the path it opens is exactly THIS one, the auto-land.renderReviewNoticefires only onescalated, which an un-blocked finding never reaches; a rendering function nobody calls is not a control.So, on the combined
land/autoLand: truebranch, BEFORE applying the accept labels: takefindings = buildPanelFindings(lensFindings)merged with the validator jury'sbuildPanelFindings(validatorFindings), and test each forhasUncapturedPrevention(f) === trueANDblocksAcceptance(f) === false— a guard the BAR un-blocked. If ANY finding matches, you MUST postrenderPanelComment({ findings, verdict, disposition, lensVerdicts })as a PR comment (node scripts/review-core-cli.mjs comment --file=<result.json>→gh pr comment <num> --repo <repo> --body-file -) first, so the impact each finding declared and the guard it still owes are on the record where someone can dispute them before the merge. Only then apply the labels.If NO finding matches — a clean accept with nothing the bar un-blocked — post nothing. The emission is deliberately conditional: making every land noisy would train the operator to skim past the one comment that carries real prevention debt. The guarantee this control makes is therefore narrow and exact: no land that the bar un-blocked happens silently. Say it that way anywhere you restate it — a claim that every finding is posted on every land would be false.
-
escalate(verdictneeds-human— a genuine mandateconflictor the globalhumanRequiredconflict-of-interest flag — ORchangeswithround >= roundCap) → this is thederiveReviewDispositionDEADLOCK case (mandate-conflict/non-convergence→{ mode: human }): the loop already ran and could not agree, so hand it to the human — do NOT re-enter convergence. Applyreview:human(neverreview:changes/author-bounce — that path is retired by v2) and post BOTH the round-by-round findings history ANDrenderPanelVerdictTable({ lensVerdicts, mandatoryLenses })(the per-lens mandatory/advisory/verdict breakdown) as a PR comment, so the human sees exactly which lens(es) disagreed and whether via non-convergence or a genuine mandate conflict. Then report it the same way as the gate-self case, viarenderReviewNotice({ event: 'escalated', pr, repo, verdict, disposition, reasons, findings })(#2433) — again passingfindings = buildPanelFindings(lensFindings)so aprevention-outstandingescalation names the guards owed in the same line. This is the only escalation shape agents produce; the operator clears it with/review <PR>. -
continue(verdictchanges,round < roundCap) → step 4.
-
-
Editor round. Spawn a fresh-context editor subagent seeded with
buildEditorMandate({ findings, round, roundCap }), wherefindingsisbuildPanelFindings(lensFindings)— the WHOLE panel's lens-tagged findings merged into one list (not just one lens) — so the editor sees every mandate's concerns in one pass. It does its writing in an isolated throwaway clone of the PR branch (never the drain's shared checkout — the #2336 constraint applies to the editor too), then pushes back to the SAME PR branch (the PR updates in place; no new PR opens). It must fix each finding or explicitly dismiss it with a stated reason (never drop one silently) — a dismissal of a MANDATORY lens's finding is exactly what the next round's reviewer for that lens re-checks.The editor is the ONE spawn here that stays a subagent, and that is deliberate (#3145). It authors; independence is a property of the judge, and
judgePanelhas no editor to be — its seats are tool-free and answer a forced findings schema. What the invariant needs is that the editor is not one of the actors that judged it, and after this change that holds by construction: every juror is a headless process with its own session id, and the editor carries the drain's. Giving the editor its own tool-bearing headless spawn is a real but separate change — #xl5jroq. -
Next round. Re-fetch the now-updated diff and re-run the panel shim with the round's new
buildPanelMandate()payload — and a--run-idcarrying the new round number (drain-<pr>-r<round>), so round N+1's seats are different actors from round N's rather than the same derived ids re-judging their own prior verdicts. No memory of the prior round travels: a headless juror has none to begin with, which is the fresh-context property the old wording asked a subagent for and could not get. → back to step 2 withround + 1.
The pushed revision re-runs the PR's required
testcheck — a round's editor commit is a normal PR update, not a merge, so a red check simply blocks that round's land/continue decision until it goes green, same as any other PR. -
The label must exist.
review:humanis provisioned like the otherreview:*labels (see #2262/#2279); if it is missing,gh pr edit --add-label review:humansilently no-ops. Ensure it exists once:gh label create review:human --description "conflict-of-interest: gate-self edit, a human must review" --color B60205 --force.
Exit codes (surface these)
0= swept clean (merged 0+ qualifying PRs, none failed) or a dry-run.2= at least one merge attempt FAILED (surfaced per PR). A deferred PR (blocker unlanded) is not a failure.
Legacy queued.json fallback
The old queued.json / scripts/lane-drain.mjs couple-drain is retired as the primary path — new producer
output opens a ready-to-merge PR (above), never a queued.json-only couple. lane-drain.mjs stays only as a
no-op fallback for any legacy couple still sitting in queued.json (node scripts/lane-drain.mjs drain --dry-run shows it; it is a clean no-op when the queue is empty). Do not reach for it unless a dry-run shows a
stranded legacy couple.
Guardrails
- Re-uses the shared transport, never re-implements it: PRs land via the same self-approved
gh pr merge(0 required reviewers + the requiredtestcheck)/pruses — never--admin, never a rawgit merge/git pushofmain. - Never force-updates a PR branch — a
BEHINDPR (needs rebase) is left for its author / a later pass, never force-rebased by the sweep. - Label-scoped: with
--label=ready-to-mergethe sweep only touches PRs a producer certified (#2196). Bare (/merge, no label) it also sweeps orphan AI PRs on the every-commit-AI gate — see/merge. - Rebase-drop can livelock on an overlapping batch —
--no-rebase-dropbreaks it. When severalready-to-mergePRs in the SAME batch overlap on the same files (e.g. multiple PRs each touchingscripts/merge-ai-prs.mjs/scripts/readiness/lane-manifest.mjs), every pass rebase-drops whichever tips read as BEHIND/CONFLICTING, which pushes a NEW commit → resets each tip'stestcheck to pending → the tip is behind/pending again at the next merge attempt → rebuilt again. The check-reset outruns the poll, so no tip stays green long enough to land. Observed 2026-07-10: 12--watchpasses, 0 merges,mainnever advanced, even though tips individually reached CLEAN+green between passes. Remedy: stop the churn and run one pass with--no-rebase-drop— it skips the commit-fabricating rebuild and merges every PR GitHub already reports CLEAN + green directly (GitHub's merge-commit strategy handles a not-behind mergeable PR). This landed 3 of 4 overlapping PRs in a single pass. A genuinely BEHIND straggler is then left as a skip; land it on a follow-up pass (a normal rebase-drop pass rebuilds the LAST one cleanly, no siblings left to race). Relates to #2391 (drain dual-lock / whole-process critical section).