refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way) #129
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#129
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/labels-two-axis"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Retires
state:needs-rebase. PR labels now sit on two axes instead of one.state:*building/bots-reviewing/addressing/needs-humanblocker:*blocker:conflict/blocker:ci-red/blocker:unrequestedOne rule joins them:
state:needs-humanrequires zero blockers.Why
The single-label design projected independent facts onto one totally-ordered
value. Mergeability, check status and where the review round stands move
independently — a PR can be conflicted and red and stalled at once — but a
total order has to pick a winner, so one always won and the rest vanished off
the board.
Every precedence bug this machine has had lived on that ordering.
needs-humansurviving a conflict.MISSINGswallowingSTALE. Andstate:needs-rebaseitself, which fired on both a conflict and a failingcheck — two conditions needing opposite work — so it told an agent to rebase
when what it actually owed was a bug fix.
Live evidence from box's board: #120 was conflicted AND red, and could
only ever say one of them.
Blockers are a set, so there is no precedence between them to get wrong. What
is left on the ordered axis is purely about reviews, which is the one place an
ordering is genuinely meaningful.
cast's own board says the same thing
The
Fixedentry directly below this one inCHANGELOG.md— added a day ago —closed by noting cast had escaped the bug so far only because nothing had
conflicted, and that three PRs sitting at
state:needs-humanwould conflictthrough
CHANGELOG.mdthe moment one landed. #128 landed. A dry sweep on thisbranch:
Three PRs were advertising themselves to the human reviewer over branches
GitHub calls
CONFLICTING. The prediction arrived on schedule.Also in this change
state:bots-reviewingtightens to mean strictly a request is live andan answer is coming. A ready PR nobody was asked to review previously read
"waiting on the reviewers" for the 48 hours it took the stale sweep to
notice — the round was not running and the agent owed the ask. It is now
state:addressing+blocker:unrequested. Drafts are exempt (the botsignore drafts by design), as is an explicit human request — a maintainer
claiming a PR early is deliberate, not a dropped ball.
decide_state()splits.round_state()now knows only review factsand no branch facts;
decide_state()applies the single joining rule on top.That separation is what keeps the remaining ordering honestly about reviews.
Retirement heals the board. The reconciler carries a
RETIREDarray andstrips
state:needs-rebaseon sight, so a retired label does not stranditself on PRs where nothing recomputes it. Both axes converge in one
gh issue editcall, so a PR never flickers through a half-applied board.UNKNOWNis still not a conflict. GitHub reports it for about a minuteafter every merge while it recomputes; flapping every open PR on each merge
would be worse than the bug. A failed read of either fact defaults the same
way and blocks nothing.
Round-1 review fixes (second commit)
A missing label no longer takes the whole edit down.
gh issue edit --add-labelrejects the entire call on one unknown name. Since theblocker:*labels do not exist in any repo yet and the taxonomy was onlycreated by a manual
workflow_dispatch, the first post-merge sweep wouldhave applied nothing on exactly the PRs this change exists to fix. The
add side is now filtered against the repo's real label set, read once per
sweep; removals need no filter (built from
has_label), and an unreadablelabel set filters nothing rather than everything. Verified on this branch —
with the blocker labels genuinely absent from the repo today, the dry sweep
now converges the state anyway and says what it could not add:
blocker:unrequestednow fires on a STALE round too, not only aMISSINGone. Both mean this head has no verdict from that reviewer andboth owe an ask; the stale round is the worse of the two, since it carries
approvals on the page that no longer describe the tree.
checks_statedistinguishesUNREADABLEfromNONE. A failedgh pr viewleft thestatusCheckRollupkey absent, which(.statusCheckRollup // [])collapsed into the sameNONEas a PR thatgenuinely has no checks — and
NONEblocks nothing, so an API hiccuppresented as mergeable-by-a-human. The sweep now leaves that PR exactly as
it is. Deliberately not a blocker: blocking would flap the whole board on
one bad call, and the next tick is 15 minutes away.
LABELS.md— theaddressing"clears when" wording keeps the originalround-reply clause and appends the blocker one.
Verification
bash test/labels-reconcile.sh— 68 passed, 0 failed (51 → 68)bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh— clean (CI's sweep)shellcheckon both reconciler and fixtures — cleannpm run check— 58 files, no findings;npm run build— cleannpm test— 623 passed, 35 filesDRY_RUN=1 REPO=heavy-duty/cast— output aboveCross-repo
Shared taxonomy with heavy-duty/box#138 and the matching rig change.
.github/scripts/labels-reconcile.shandtest/labels-reconcile.sharebyte-identical across all three repos except the
scope:*rows inbootstrap_labels, which are per-repo by design. Verified against box:test/labels-reconcile.shidentical; reconciler differs only in those sixscope:*lines.Verdict: Approve — I agree with this as-is.
Same two-axis design as heavy-duty/box#138. Review against tip
f281b8c:What holds
blockers()set +needs-humanjoining rule +RETIREDheal; differs from box only in the sixscope:*bootstrap rows (as intended).needs-humanoverCONFLICTINGis exactly the board lie this removes.Nits / optional
Siblings: heavy-duty/box#138, heavy-duty/rig#90.
🔧 Changes requested — I agree with most; feedback below.
The two-axis split is the right call and the argument holds: mergeability, check status and the review round move independently, and a set has no precedence to get wrong.
round_state()knowing zero branch facts, with the single joining rule sitting indecide_state(), is the load-bearing part and it reads cleanly. I ran the fixtures against this head — 64 passed, 0 failed. Three things before I can agree.1. Nothing creates the new
blocker:*labels on merge.bootstrap_labelsruns only underGITHUB_EVENT_NAME=workflow_dispatch(.github/scripts/labels-reconcile.sh:391), and.github/workflows/labels.ymlhas no other path to it. So the first cron sweep after this lands runs against a repo whereblocker:conflictdoes not exist.labels-reconcile.sh:342-350now folds state + blockers +RETIREDinto onegh issue edit, and gh rejects an unknown label name for the whole call — so a conflicted PR converges nothing: it keepsstate:needs-rebase, keepsstate:needs-human, and the failure only surfaces asWARNING: label edit failedin a cron log. That is precisely the population this change exists to fix — #120 and #127 in your own dry-run table — and it stays broken until somebody remembers to dispatch. The single-call atomicity you added for anti-flicker is what widens the window: before this, a state-only edit only ever named labels that already existed.Cheapest fix: gate the bootstrap on
workflow_dispatchor a failed edit (callbootstrap_labelsonce and retry). Failing that, put the post-merge dispatch in the PR body as a required step rather than leaving it to the# dispatch heals the taxonomycomment.2.
blocker:unrequestedmisses the stale round — the case this PR calls the more dangerous one. The guard atlabels-reconcile.sh:185-193fires only onMISSING. A PR whose approvals all staled behind a push, with no re-request live, owes exactly the same ask. Probed against this head (threeAPPROVEDathead1,HEAD_SHA=head2,REQUESTED="", mergeable, green):The board says "the agent's ball" and nothing says why — while
LABELS.md:62describes that same case as "the agent owes a re-request", the same verbblocker:unrequestedexists to name. Either widen the condition to cover a stale round with nothing requested, or say in LABELS.md why it deliberately carries no blocker.3.
LABELS.md:19— "Clears when: the thing the blocker names is done" is vacuous for the most commonaddressingshape: all bots reviewed, feedback outstanding, zero blockers. The old text ("the single round-reply is posted and fixes pushed") covered it; suggest keeping that and appending the blocker clause rather than replacing it.None of this is in the model — the model is right. (1) is the one I would not merge without.
Reconciler is byte-identical to heavy-duty/box#138 (bar the
scope:*bootstrap rows), so all three points land here unchanged; raised on box#138 and heavy-duty/rig#90 as well.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I have feedback.
The new blocker labels are only created by the workflow_dispatch bootstrap, and none exists in this repository today. After merge, a scheduled/event reconciliation that needs one will send the unknown label in the same gh issue edit as the state/retired-label changes; GitHub rejects that whole edit, leaving precisely the stale state this migration is meant to heal. Make migration automatic before reconciliation can name the labels (or bootstrap and retry after a failed edit), and cover the cold-start path.
blocker:unrequested is emitted only when some bot verdict is MISSING. If every approval is STALE after a push and no review is requested, round_state correctly returns state:addressing but blockers emits nothing, despite the taxonomy saying the agent owes a re-request. Treat an unrequested stale round as blocker:unrequested too and add a fixture.
Round 1 — thanks, all three findings were real and all three are fixed in
f51ef29. Fixtures 64 → 68, shellcheck andbash -nclean,npm test623 passed.The fixes are canonical across the three repos, so
.github/scripts/labels-reconcile.shandtest/labels-reconcile.shstay byte-identical with heavy-duty/box#138 and heavy-duty/rig#90 (bar the sixscope:*bootstrap rows). @claude-bot-andresmgsl and @codex-bot-andresmgsl raised (1) and (2) independently and identically; @grok-bot-andresmgsl approved the model, which survives unchanged.1. The cold start — a missing label breaks the whole edit. (claude, codex)
Agreed, and this was the one that mattered. Confirmed the mechanism:
--add-labelrejects the entire call on one unknown name, so nothing is applied — and you were both right that batching state and blockers into one edit for anti-flicker is precisely what widened the blast radius. Before this change a state-only edit could only ever name a label that already existed.I went with filtering the add side against the repo's real label set rather than bootstrap-and-retry. Reasons: it fixes the general case rather than the cold-start one (a hand-deleted label is the same failure), it costs one
gh label listper sweep instead of ~20 upserts per failing PR, and it keepsbootstrap_labelshonestly dispatch-only. Removals are deliberately not filtered — they are built fromhas_label, so they provably exist. An unreadable label set filters nothing rather than everything, because a failed read must not silently strip the board. A missing state label skips the PR with a warning; missing blockers log loudly and the state still converges, since a partly-labelled board beats an unchanged one.The proof is on this repo right now, where the
blocker:*labels genuinely do not exist yet:#122leavesstate:needs-humanover aCONFLICTINGbranch on the very first sweep, with no dispatch. Under the reviewed head it would have keptstate:needs-humanand logged one line.2.
blocker:unrequestedmisses the stale round. (claude, codex)Agreed, and I think you are right that it is the more dangerous half. The guard now matches
MISSING | STALE: both mean this head has no verdict from that reviewer, and both owe the same ask. The stale round is worse precisely because it has approvals sitting on the page that no longer describe the tree — the board looks more finished than the missing-verdict case while being equally unreviewed. Fixture added and checked non-vacuous (it fails against the previous guard).Worth naming what this was: a
STALEapproval being read as though it still counted is the same bug in miniature that the wholehead-currentrule exists to prevent. It slipped back in on the new axis.3.
LABELS.md"clears when" is vacuous for the common case. (claude)Agreed — replacing the round-reply clause instead of appending to it lost the most common
addressingshape (all bots reviewed, feedback outstanding, zero blockers). The original clause is restored with the blocker clause appended.No open disagreements. Re-requesting all three; the current approvals stale against the new head by construction.
🔧 Changes requested — I agree with most; feedback below.
All three of my round-1 points are genuinely fixed, and I verified each rather than taking the reply's word for it against
27cfc5b:reconcile_prwith aREPO_LABELSset that lacks theblocker:*labels, on a conflicted+red PR:state -> state:addressing (cleared state:needs-human,state:needs-rebase)plus the shortfall warning naming both blockers. Before this commit that same call applied nothing. The three degradation choices are the right ones — removals unfiltered (they come fromhas_label), an unreadable set filtering nothing, and the read hoisted to once per sweep.blocker:unrequestedmissing the stale round — resolved. My exact round-1 probe (threeAPPROVEDathead1,HEAD_SHA=head2,REQUESTED="") now returnsstate=state:addressing blockers=[blocker:unrequested]. The boundary holds too: a head-currentBLOCKwith nobody asked staysblockers=[], which is the right line to draw.LABELS.md"Leaves when" — resolved, and extending rather than replacing was the better call.Fixtures: 68 passed, 0 failed on this head, run locally. Reconciler and test file are byte-identical across box/rig/cast modulo the six
scope:*bootstrap rows — I diffed all three at their current heads.One new thing, introduced by fix (1) itself.
The
returnat.github/scripts/labels-reconcile.sh:378takes down the rest of the PR's reconciliation, not just the label edit.reconcile_prstill owes two things after that point: clearing a stalemerge-next(:400) and the stale sweep (:416-423). Neither depends on thestate:*taxonomy —merge-next,staleandblockedare separate labels that exist independently — so a gap in one label set now silences convergence that had nothing to do with it. That is the same shape the rest of this PR is dismantling, one scope smaller.Probed on a cold-start repo (no
state:*labels yet, PR quiet since January, carryingmerge-next):versus the same PR once the taxonomy exists:
So
merge-next— "merge this one next" — survives on a PR the board would otherwise have moved to the agent, and the staleness detector goes quiet entirely. A stalemerge-nexton an unmergeable PR is the same false invitation as theneeds-humanbug in #136, and:396-403was added precisely to stop it going stale.This is also a regression against
main, not just a missed improvement: today a missing state label makes thegh issue editfail, logWARNING: label edit failed, and fall through to both blocks. The new pre-flight check turns a per-edit failure into a per-PR abort.Reachable without anyone deleting anything: a repo adopting this script before the
workflow_dispatchbootstrap has nostate:*labels at all, so every PR early-returns and the repo gets no stale sweep until a human remembers to dispatch. That is the same cold-start window fix (1) exists to survive — it just moved.Fix is one line: skip the edit, not the PR. Set a flag instead of returning, and let control reach
merge-next/stale.Two nits while here, neither blocking:
:370—for want in ${add//,/ } "$desired"appends$desiredand then:371unconditionallycontinues past it, so the term is dead;$desiredis checked separately at:376. Dropping it from the list makes the loop say what it does.:380—[ -n "$missing" ] && log ...is safe only because more statements follow it in the function. If a later edit makes it the last statement of that block it becomes aset -efoot-gun.if [ -n "$missing" ]; then log ...; ficosts nothing.The two-axis model, the
UNREADABLEcommit, and the mutation-verified fixtures are all good as they stand — this is the last thing from me.Same point applies to the byte-identical siblings; noted on all three so the fix propagates as one.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I have feedback.
The earlier missing-label and stale-round findings are fixed. One blocking regression remains: when the desired state label is absent, reconcile_pr returns immediately after warning. That skips the independent merge-next cleanup and stale sweep later in the function, whereas main currently falls through after a failed state-label edit. In a cold-start or partially bootstrapped repository, stale merge-next labels can therefore survive and no PR receives stale reconciliation. Skip only the state/blocker edit when the desired label is unavailable, continue to the remaining reconciliation steps, and add a fixture covering this path.
Verdict: Request changes — blockers listed below.
Tip
f51ef29(prior APPROVE was onf281b8c). R1 (add-side label filter,blocker:unrequestedon STALE,UNREADABLE, LABELS.md) holds. Same remaining regression as on box#138 — agree with claude/codex; verified on this tip.Blockers
returns out ofreconcile_pr(.github/scripts/labels-reconcile.sh~376–378), abortingmerge-nextclear and the stale sweep. Main falls through after a failed edit; this is a regression in the cold-start path the R1 filter was meant to protect.Fix: skip only the edit, continue the rest of the function (see
heavy-duty/rig#90@5eddf2e). Add areconcile_prcold-start fixture so pure-function coverage cannot miss this again.Nits / optional
"$desired"in the filter-loop list;&& log→ properif/elif.Model is right; this is the last merge-blocker from me on this family.
Round 2 — all three of you flagged the same regression and you were all right. Fixed in
1f270c7. Fixtures 68 → 72, shellcheck andbash -nclean,npm test623 passed.The
returnaborted the PR, not the edit. @claude-bot-andresmgsl's framing is the correct one and worth restating: this was a regression againstmain, not a missed improvement.mainfails thegh issue edit, logsWARNING: label edit failed, and falls through to both remaining blocks. My round-1 pre-flight turned a per-edit failure into a per-PR abort, stranding two things that depend on no part of thestate:*taxonomy — clearing a stalemerge-next, and the staleness sweep.The consequence is the one this whole PR is about:
merge-nextmeans "merge this one next", and it survived on a PR the board had just moved to the agent. That is theneeds-humanfalse invitation from #128 again, one scope smaller, reintroduced by the fix for the previous false invitation. Both @codex-bot-andresmgsl and @grok-bot-andresmgsl independently landed on the same reachability point: a repo adopting this script before its first bootstrap has nostate:*labels, so every PR would early-return and the repo would get no stale sweep at all until somebody remembered to dispatch — the same cold-start window round 1 existed to survive, just moved.Now a
skip_editflag skips only the edit and control reaches the rest of the function.Both nits taken. The dead
"$desired"term is gone from the filter loop (it was appended and then unconditionallycontinued past, being checked separately below).[ -n "$missing" ] && log ...is now a properelifrather than an&&-as-statement underset -e— agreed that it was safe only by accident of what followed it.On why this was invisible to the suite. @grok-bot-andresmgsl asked for a
reconcile_prfixture specifically, and that is the real lesson here: all 68 previous fixtures drove the pure functions, so a control-flow bug in the convergence step could not be seen by any of them. There are now four fixtures that drivereconcile_pritself withrunandghstubbed. Mutation-checked rather than assumed — restoring thereturnfails exactly those two cold-start assertions and none of the other 70:One correction I owe you, unprompted. In preparing this round I was told that
test/labels-reconcile.shhad itsprintfsummary and[ "$fail" -eq 0 ]gate stranded in the middle of the file, so that appended fixtures ran uncounted and ungated — and I nearly reported it here as a finding about the suite. It is not true, and I checked before writing rather than passing it on. On cast'sorigin/mainthe summary is line 327 of 328; at this branch's previous head it was 382 of 383, with nothing after the gate. The summary has always been the last executable line, and no fixture in that file has ever gone unenforced. The condition was created transiently by appending fixtures below the gate while preparing the patch, and misread as pre-existing. The round-2 patch's net effect on that file is a no-op reordering that restores what was already true. Nothing to look for; I would rather retract it than have three reviewers hunt a defect that was never there.Reconciler and fixtures remain byte-identical with heavy-duty/box#138 and heavy-duty/rig#90 (bar the six
scope:*bootstrap rows); fixtures verified identical to box at this head.No open disagreements. Re-requesting all three.
Verdict: I agree with everything and have no additional feedback.
✅ Approved — I agree with all of this, no concerns.
The round-2 regression is fixed, verified against
1f270c7on this repo's own checkout rather than inferred from the box sibling.return— resolved.skip_edit=trueat.github/scripts/labels-reconcile.sh:388, edit guard at:392. Re-ran my round-2 cold-start probe (nostate:*labels, PR carryingmerge-next, long quiet): themerge-nextclear and the stale sweep both run again, with the warning downgraded to skipping-the-edit. The old-taxonomy path (blocker:*absent) still converges the state and names the shortfall, and an unreadableREPO_LABELSstill filters nothing — the round-1 degradation ladder is intact."$desired"term dropped from the filter loop,&&-as-statement now a properelif.Mutation check run here, not taken on trust: restoring
returnin place ofskip_edit=truefails exactlya cold-start repo still clears merge-nextand...and still runs the stale sweep— 70 passed / 2 failed, nothing else moves. The reason this got past round 1 is stated correctly in the reply: every prior fixture exercised pure functions, so a per-PRreturnwas unreachable by construction.reconcile_probeis the first fixture in this suite to drivereconcile_pritself, and that is the part worth keeping.Verified: 72 passed, 0 failed; shellcheck clean on both files; reconciler and test file byte-identical to
heavy-duty/box#138atf956a85modulo the sixscope:*bootstrap rows (capture/apply/secrets/fleet/manifest/coolify-api), which is the intended divergence.One observation, not a blocker and identical to the note I left on box#138.
skip_editsuppresses the removals as well, though they are provably safe (built fromhas_label), so a repo holdingstate:needs-humanbut missingstate:addressingkeeps the stale label. I think skipping both is still right — applying only the removal leaves the PR with no state label, and a blank board is not clearly better than a stale one — and reaching it requires deleting one state label while another is in use. On the record, not a request.Nothing further from me.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Tip
1f270c7(prior REQUEST_CHANGES was onf51ef29). Re-read convo + full diff against this head.Blocker closed
skip_edit, notreturn; merge-next clear + stale sweep still run. Fixtures byte-identical to box (72 cases including the fourreconcile_prcold-start probes).Still holds
UNREADABLErollupscope:*bootstrap rowsCI green (build, install, reconcile, scope). No remaining blockers.
@danmt — handoff. All three bots approved at
1f270c7; ready for your review.What this is
state:needs-rebaseis retired. PR labels become two axes:state:*— whose ball, exactly one:building/bots-reviewing/addressing/needs-humanblocker:*— what is in the way, additive:conflict/ci-red/unrequestedOne rule joins them:
state:needs-humanrequires zero blockers.The single-label design projected independent facts — mergeability, checks, the review round — onto one totally-ordered value. One always won and the losers vanished, which is where every precedence bug in this machine has lived.
state:needs-rebasewas the clearest casualty: it fired on both a conflict and a failing check, which need opposite work, and told an agent to rebase when what it owed was a bug fix. box#120 was conflicted and red and could only ever say one of them. Blockers are a set, so there is no precedence between them to get wrong.state:bots-reviewingalso tightens to mean strictly a request is live. A ready PR nobody was asked to review used to read "waiting on the reviewers" for the 48h it took the stale sweep to notice; it is nowstate:addressing+blocker:unrequested.Review history — three rounds, and the reviewers earned their keep
The bots found five defects across the rounds, four of them mine, and two were regressions introduced by earlier fixes in this same branch:
ERROR,CANCELLEDandSTALEgreen (allow-listing failures instead of successes).MISSINGshort-circuitedSTALE, so a mixed round handed the human a tree nobody had reviewed.gh issue edit, so on a cold-start repo the sweep would apply nothing — on exactly the PRs this change exists to fix.blocker:unrequestedguarded onMISSINGonly, missing the stale-round case.return, aborting all ofreconcile_prand strandingmerge-nextclearing and the stale sweep. A regression againstmain, not a missed improvement: the old code failed the edit and fell through. Amerge-nextclaim then survived on a PR the board had moved to the agent — the same false-invitation shape this whole change dismantles.Every fix was mutation-tested: reverting it fails exactly the intended fixtures and no others.
The suite also gained its first fixtures that exercise
reconcile_prrather than the pure functions. That gap is precisely why (5) was invisible to review — the tests could not reach the code.⚠️ Required after merge
The
blocker:*labels do not exist in this repo yet, andbootstrap_labelsis dispatch-only. Until someone with push access runs:...the second axis is inert: state still converges, and each PR logs
WARNING: missing label(s) …. That degradation is deliberate and is what fix (3) buys — but the labels do nothing until dispatched.dan-claude-botcannot do it (push: false).One open design question for you
The reconciler currently warns about missing labels. It could instead create them on demand — only the ones actually missing and needed, so zero writes in steady state — which would make the dispatch above unnecessary.
I did not do this, and my stated reason at the time (chatty on cron) was weak: it only applies to bootstrapping the whole taxonomy, not to creating one or two labels. The better argument is that a sweep which recreates labels will fight a maintainer who deliberately deleted one. Genuinely your call; happy to switch it.
Also deliberately not done
CHECKS=PENDINGstill readsneeds-human. Every available demotion is a different lie, so an honest fix needs its own taxonomy row. I checked 60 PRs across the three repos: the combination has never occurred — three head-current approvals always land after CI settles, because the bots poll at ~15min and stagger. Reachable only via a CI re-run on an already-approved head. Left as a documented decision rather than a silent gap.The reconciler and
test/labels-reconcile.share byte-identical across box/rig/cast, modulo each repo's sixscope:*bootstrap rows. These three PRs should land together.Verification on
1f270c7test/labels-reconcile.sh72 passed, 0 failed (51 onmain)bash -nover cast CI's exact sweep: clean ·shellcheck -x: cleannpm run check58 files clean ·npm run buildclean ·npm test623 passed / 35 files