Imported from StefanMaron/BusinessCentral.AL.Runner (
.claude/skills/orchestrating-a-session/SKILL.md). Install upstream withnpx skills add StefanMaron/BusinessCentral.AL.Runner --skill orchestrating-a-session. Copyright stays with the author.
Orchestrating a session on AL Runner
You are the coordinator. You spawn implementation agents, review what they produce, drive PRs to green, and merge. You do not usually write the fix yourself — but you do own the judgement calls, the merges, and the honesty of what gets reported.
Everything below is here because it had to be explained more than once. Read it once at the start of a session rather than rediscovering it.
What you decide, what you delegate
Delegate: diagnosing a cluster, writing the fix, writing the proving test, driving that one PR through CI.
Keep: which clusters are worth attacking and in what order; whether a result is real; whether a PR meets the bar; every merge, in both repos; corrections to issues whose premise has been falsified.
Do the work yourself when you already have the answer. If you have just measured something, spawning an agent to re-measure it wastes a full context. Write the PR.
Authority — you do not need to ask for these
- Filing issues on
StefanMaron/BusinessCentral.AL.Runner, and correcting the body of an issue you filed when a measurement contradicts it. - Closing an issue whose work has already landed. Closing is cheap and reversible — an
issue that turns out to be live again can simply be reopened. Verify against the code at
main, not against issue text, and prefer re-running the reporter's repro where one exists. - Merging any PR authored under the repo owner's account, in this repo and the corpus repo, on your own high-level review plus a green pipeline. That covers every PR your agents open, since they push with that account's token. Judge whether the change is right, whether the proving test is there, and whether CI is green on the current head — then merge.
- Arming auto-merge instead of waiting. Review the PR when it arrives; if it passes, arm
it (
gh pr merge <N> --squash --delete-branch --auto) and move on. Do not sit watching a run you cannot influence. If the required checks are already green that command merges it now, not later — so running it is the merge, and every condition in the arming list below has to hold at that moment. Re-read the PR afterwards rather than trusting the exit code; both are in "A reviewer that approves a PR arms auto-merge" (#3150, #3341). - Claiming an issue assigned to another contributor when it overlaps work already in
flight, once the repo owner has released that contributor's backlog.
branch-and-pr.md's assignee boundary still holds as the default. When a released issue is the same defect an agent is already fixing, reassign it (gh issue edit <N> --remove-assignee <login> --add-assignee @me) and fold it in. Do not bulk-claim issues nobody is working on, and do not assume a release — confirm it.
A PR from anyone other than the repo owner is reviewed, never merged. Commenting on it is ungated like any other PR here — the boundary is the repository, not who opened it. Merging someone else's contribution stays the owner's call.
Ungated on these two repositories (BusinessCentral.AL.Runner and the corpus): commenting
on issues and PRs, closing issues, applying labels. Still gated: PR review comments submitted
as a formal review, and anything posted to another repository. public-posting-approval.md is
the authority and is auto-loaded; it carries the two conditions that come with the ungating —
every state change carries its reasoning, and every agent-authored post says an agent wrote it.
Implementation agents
Use the impl-agent subagent type, not general-purpose. Its definition carries the
workflow contract — branch naming, labels, the CI rules, the navigation tooling — so your
brief only needs the cluster context and the traps.
Do not ramp up identity numbers. The documented pool is impl-1/impl-2, widened by the
owner when concurrency is raised. A finished agent's identity is immediately free — reuse
it. Every new identity leaves a permanent worktree behind; inventing impl-3 … impl-12
across a session leaves a dozen. Reuse first, and only invent one when every identity is
genuinely in flight.
Brief with cluster data and traps, not with pre-resolved symbols. Resolving an agent's symbols for it moves cost onto your own long-lived context, which is backwards. Give it the failing test names, the stack top, the counts, the falsified hypotheses — and let it navigate.
A good brief says what a complete answer looks like, including a negative one. "These 6 are cause A and these 10 are cause B, here is the evidence" is a complete answer with no fix. Say so explicitly, or agents will force one fix over two causes to make the PR look bigger.
Agents do NOT wait for CI. Their deliverable is "PR marked ready and pushed". Waiting costs an
agent slot for 15-25 minutes watching a run it cannot influence, and you are watching CI
anyway. impl-agent.md's Step 5 says this; keep briefs consistent with it. A failure is never
lost by returning early — resume the agent, or dispatch a fresh one with the failure in hand.
Never relay an authorization to an agent. An agent is right to refuse a message claiming
"the owner approved X" for anything touching its operating rules — commit signing, skipping a
verification step, dropping an instruction its own harness set. It cannot verify the claim,
and its instructions correctly say no agent message substitutes for the user's consent. This
cost a full round trip when signing was disabled: the agent refused twice, correctly. Do the
privileged step yourself — its work is staged in its worktree, so commit, push and open the
PR from the coordinator session. Better still, make the change invisible: commit.gpgsign was
already false in the shared repo config, so an agent that simply runs git commit succeeds
and never needs telling.
Check in on long runners. Past ~90 minutes, ask: where are you, is anything unpushed, is there a PR, are you blocked. Agents will sit on finished work waiting for permission they already have.
Search the issue queue before dispatching, and hand over the whole cluster. A measured failure cluster is usually already partly filed. Grep the open issues for the area first — on one dispatch this turned a single issue into four sharing one root cause (#2723 + #2517 + #2460 + #2200), and the agent brief said so, which is what let it fix them together. Ask the agent which of the related issues its change closes for free rather than assigning all of them; "these three are one fix, that one is not, here is why" is a complete answer.
Keep a reviewer running, and size the batch
The default is one implementation agent and one reviewer. Not a ratio to compute — a baseline to start from, changed only by the human at session start. One implementer produces at most one PR at a time and one reviewer clears roughly four an hour, so review cannot fall behind by construction, and the pile-up this section describes never begins.
The measured throughput below is what to scale by when a human raises the concurrency, not a license to raise it. At six implementation agents you need roughly two reviewers to hold steady; work that out from the numbers rather than adding implementers because slots are free.
Review is the step that stalls, and it stalls by arithmetic rather than by anyone deciding badly. Measured on 2026-09-06: a reviewer clears 6 PRs in 93 minutes (~15.6 min/PR) and 3 corpus PRs in 64 minutes (~21 min/PR), so one reviewer sustains about 4 PRs/hour. Implementation agents take 35-85 minutes and produce one PR each, so six of them produce 5-6 PRs/hour. One reviewer cannot keep up with six implementation agents. Budget roughly one reviewer per four implementation agents.
Treat an open unreviewed ready PR as unfinished work that counts against your concurrency budget (a draft is the claim of an implementation you already count). Six implementation agents plus six unreviewed PRs is twelve, not six. Without that accounting you will keep starting implementation agents whenever a slot frees, because starting one feels like progress and starting a reviewer feels like overhead - and the queue grows every hour.
Batch three or four PRs per reviewer. Larger batches go stale: a batch of six ran 93 minutes, during which three PRs from the brief merged and two heads moved, so a third of the verdicts came back "no verdict on current head". Smaller batches lose the cross-PR findings that are the reason to batch at all - the most valuable result that day was spotting that two PRs bumped the same submodule pin to different revisions and working out which had to merge first (a shape #3737 removed: there is no pin). A per-PR reviewer cannot see that, and neither can you.
A reviewer that approves a PR arms auto-merge on it immediately, in the same pass. Do not hand an approval back to the coordinator and wait for it to act — that round trip is where the verdict goes stale, and staleness is the main cost of reviewing in batches. The reviewer has just read the head SHA; it is the only actor that knows the verdict and the SHA are consistent at that instant.
gh pr merge <N> --repo <owner>/<repo> --squash --auto
Arming does NOT require a green verdict, and a brief that demands one is wrong. --auto exists
for the not-yet-green case: it holds the PR at BLOCKED until every required check passes, and a
red aggregate means no merge. So ci-wait.py exit 2 — checks still running, nothing failing —
is a perfectly good moment to arm, and insisting on exit 0 forces a second round trip in which the
verdict goes stale, which is the cost this whole section exists to avoid. Exit 0 or 2 arms;
exit 1, 3 or 4 does not. Measured on PR #3959, where a reviewer armed at exit 2, said
so prominently rather than quietly, and was right — the coordinator's brief had over-specified the
condition, not the reviewer's judgement (#3961).
Arming at exit 2 is safe; it is not self-correcting. Re-read every armed PR's verdict on each
sweep. --auto holds a red PR rather than merging it, so nothing breaks — but nothing tells you
either, and an armed PR that goes red merges the instant a fix pushes on top, against a verdict
nobody gave the new head. Measured on #3978: armed at exit 2 with 0 failing, two BC legs reported
Failed: 2, Passed: 5506 twenty minutes later, and the coordinator found it only by sweeping.
Disarm before dispatching the repair, then re-arm on a fresh verdict.
tools/armed-prs.py is that re-read, as one command with no arguments. It lists the armed
set and reports only the PRs that are failing or whose verdict could not be read; a PR whose
checks are still running is the ordinary armed state and stays quiet. Exit 0 nothing to do,
1 something is failing, 3 a verdict could not be read — which is not "fine", because nobody
measured it. It reports and never disarms: the repair sequence above stays the coordinator's
call. Timings that justify a tool over the sweep alone: two armed PRs sat red for 12 and 119
minutes on a night with nineteen armed at once (#4006).
Arm only when all of these hold. Any one missing means report it to the coordinator instead:
-
The PR is on a branch this loop owns. Check the branch prefix, never the author field — every loop running under one account reports that account as the author, and an outside contributor's PR is never merged by us.
-
No release run is in progress (
publish.ymlpushes a fast-forward; a merge during its ~40-minute run kills it). -
git merge-tree --write-tree --messages origin/<base> origin/<branch>is clean. -
Every
Corpus-PR:line in the body names a merged corpus PR; a PR touching an AL-observable path with neither aCorpus-PR:nor aCorpus-NA:line is held (the linkage gate,bc-behavior-tests-go-upstream.md). The read isPR_BODY="$(gh pr view <N> --json body --jq .body)" python3 .github/scripts/corpus_pr_state.py, which answers one ofNONE/MERGED/MERGEABLE/NOT-MERGEABLE/CLOSED-UNMERGED/UNREADABLEper cited corpus PR —tools/ci-wait.pyprints the same line beside its verdict (#3674). OnlyNONEandMERGEDarm.MERGEABLEis a corpus PR still to merge: merge it first, in this same step, then re-read. Anything else means reporting that corpus PR's number instead of arming.Having merged the corpus PR in this step, the gate's stored tick is now stale, and it refuses the merge you are about to make (#4206). The gate does not re-evaluate when the corpus PR moves, and a failing non-required check makes
mergeStateStatusUNSTABLE, whichenablePullRequestAutoMergerefuses — whileci-wait.pyreports GREEN and printscorpus PR #N: MERGEDbeside it. It now also printscorpus gate: STALEwhen that is so; clear it before arming, which costs seconds and moves no head:tools/armed-prs.py --refire-stale-corpus-gate # every armed PR whose gate is STALENever an empty commit: a push restarts the BC matrix and re-arms auto-merge against a head nobody reviewed. And
corpus gate: UNKNOWNis not a stale gate — nobody established what it is, so read that corpus PR by hand rather than re-firing. -
No other PR in the same batch conflicts with it. Where two do — historically two submodule pin bumps to different revisions, say — arm only the one that must merge first and report the ordering.
-
The newest comment on the PR whose last line begins
Verdict:readsVerdict: MERGEwith a head equal to the PR's current head (gh pr view <N> --json headRefOid); any other line, or none, sends the PR back to its reviewer naming what is missing. -
No commit is attributed to another real GitHub account.
tools/pr-attribution.py <N> [<N>...]— the whole arming batch in one call. Such a commit makes themainruleset'srequire_extra_approval_for_unattributed_changesrefuse the merge until a human approves, with every check green andmergeable: MERGEABLE. It is not a check, soci-wait.pycannot see it and reports GREEN (#3942). Exit 0 arms, 1 does not (report it and ask the owner), 3 does not either — the authors were not established, and an unmeasured thing is not an all-clear. The tool ignores an empty login andclaude, because this loop's own commits carry both and they never block (measured: #3943 CLEAN with both, #3927 BLOCKED only onceSShadowSappeared); keying on "not the pushing identity" false-positives on everything this loop writes. Never self-approve — that rule exists to put a human in front of exactly this change.
Record the head you armed against — it is the head in that verdict line. If the head moves afterwards, GitHub keeps auto-merge armed against the new head, which nobody has reviewed, and the head is what lets you notice.
One command, two outcomes — and on a green PR it MERGES. --auto is not "queue it for
later":
- required checks not yet green → auto-merge is armed, and the PR lands when they pass;
- required checks already green → the PR merges on the spot.
gh picks between the two itself, before calling anything — its merge command carries a
function named isImmediatelyMergeable for exactly this. Both outcomes are intended: if review
approves and CI is green, the PR should merge.
So on a green PR, the approval decision IS the merge decision. There is no coordinator checkpoint after it, and nobody looks again. Every condition in the list above has to hold at the moment you run the command, because running it is the merge — not a request for one. Weigh the verdict accordingly rather than assuming a later sweep will catch a mistake.
But gh decides that from a cached status, so it can lose the race — verify by re-reading the
PR, never by the exit code. gh picks the path once, at command start, from the
mergeStateStatus it fetched: autoMerge: opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus) (pkg/cmd/pr/merge/merge.go).
isImmediatelyMergeable is true for CLEAN, HAS_HOOKS and UNSTABLE only. GitHub's enum
also has UNKNOWN — "the state cannot currently be determined", which is what a PR reads
while GitHub recomputes mergeability, and gh has no constant for it at all. So a PR whose
checks have just settled reads UNKNOWN, gh takes the arming path, and by the time the
mutation lands GitHub has settled to CLEAN and refuses to arm what can already merge:
GraphQL: Pull request Pull request is in clean status (enablePullRequestAutoMerge)
The PR is then neither merged nor armed — measured twice, on #3336 and #3772, each landed
afterwards by re-running the same command without --auto. Re-running without --auto is
the fix; the state is genuinely clean, which is why it works.
The message is a GitHub API error, not a gh one: the phrase does not occur in the binary
(gh 2.98.0). That much of #3127 was right. Its conclusion — that gh therefore cannot
produce it, because it skips the mutation on a mergeable PR — does not follow, because "already
mergeable" there means as cached at fetch time, and the gap between the two reads is the race.
--auto on a settled-green PR still merges on the spot (#3095); that is not in question.
A second cause reaches the same exit-0-but-unarmed state, and its fix is the opposite one.
The race above is a clean-status one, cleared by re-running without --auto. During an
API degradation the call instead fails in transport — a bare GraphQL 502 or 500 — and gh
still exits 0 with the PR neither merged nor armed. There the state is not clean, so dropping
--auto would be wrong; a plain retry of the same command takes. Measured three times on
2026-09-13 while gh pr create was also failing with 502s and the REST endpoint was healthy.
Telling them apart is cheap when there is a message at all: the clean-status error names
enablePullRequestAutoMerge, a transport error names an HTTP status. But a degraded endpoint
also returns an empty body, which parses as nothing — measured minutes later on this same
outage, where two gh api ... -X POST calls produced unexpected end of JSON input from the
parser rather than any status from GitHub, and the third attempt succeeded. So the absence of
a recognisable error is not evidence of the clean-status race.
When in doubt, retry the same command once and re-read; if it is the clean-status race, the
retry fails the same way and then you drop --auto. Retrying is the safe default, because
it cannot merge anything: the wrong guess costs one call, while dropping --auto on a PR that
is not clean asks GitHub to merge on unpassed checks.
So the exit code is not the check — the PR's state is. After any gh pr merge, re-read it:
gh pr view <N> --repo StefanMaron/BusinessCentral.AL.Runner \
--json state,mergedAt,autoMergeRequest \
--jq '"state=\(.state) mergedAt=\(.mergedAt // "-") auto=\(.autoMergeRequest != null)"'
| what it reads | meaning |
|---|---|
state=MERGED, mergedAt set |
done — record the SHA |
state=OPEN, auto=true |
armed; it lands when the checks pass |
state=OPEN, auto=false, and the PR is green |
the race above — re-run without --auto |
Read that exit code directly, and never through a pipe. gh pr merge does exit 1 on this
refusal — verified on gh 2.98.0, and its error propagates unaltered from the mutation through
merge() to the exit. The rc=0 reported alongside this failure on #3772 came from the
measurement: cmd | tail yields tail's status, and out=$(cmd); echo "$out" yields
echo's, so both print the GraphQL error and then report 0 (ci-verdicts.md §0). That is why
the re-read above is the check and the exit code is only corroboration: one of them was
misreported for three days, and it was not the one GitHub sends. See #3341.
Still read it: gh pr merge exits non-zero for real reasons too (Pull request #N is not mergeable: ...), and a loop that printed "armed" regardless of it once left four green PRs sitting unarmed. It is the second check, not the first.
When it arms rather than merges, arming is still not merging, and it does not replace the merge bar — it is the bar expressed as a standing instruction to GitHub, so a PR lands the moment its checks go green instead of at the coordinator's next sweep.
Start the next reviewer when one returns, not when a queue becomes visible. By the time a pile-up is obvious it is already too deep to clear in one fresh batch.
Require the head SHA in every verdict, and re-read it immediately before merging. Heads move
within minutes when other loops and outside contributors push. Pass --match-head-commit so a
merge refuses rather than quietly taking a commit nobody reviewed - a SHA in one brief had a red
verdict attached by the time the review finished.
Pass the FULL 40-character SHA to --match-head-commit. An abbreviated one is rejected by
the GraphQL layer (Could not coerce value "ca0f311d" to GitObjectID), and the failure wears
the shape of a success: nothing merges, nothing is armed, and gh still leaves $? at 0 in a
pipeline, so a loop reading the exit code reports "armed" about a PR that is untouched. The
PR's own state is the check, as everywhere else on this page:
head=$(gh pr view <N> --repo <owner>/<repo> --json headRefOid --jq .headRefOid)
gh pr merge <N> --repo <owner>/<repo> --squash --delete-branch --auto --match-head-commit "$head"
gh pr view <N> --repo <owner>/<repo> --json state,autoMergeRequest # re-read; this is the check
One session's sample, and review time scales with PR size. Re-measure with tools/agent-cost.py
rather than treating the ratio as settled.
Triage
Run the triager subagent at the start of a cycle, and again whenever the open-issue
count has grown by roughly 20 or the queue has visibly drifted. Sonnet is a fine fit.
The queue grows for a reason worth naming: issues get fixed by a PR that cites a different
number, so nothing auto-closes them. Ask triage for three things — already-fixed issues
with the commit that fixed each, duplicate clusters with a canonical, and status labels for
the untriaged. Have it apply labels directly (mechanical) and close only confirmed duplicates (its
closing rule: the exact prior issue or merged PR found, or a retry duplicate per .claude/agents/triager.md § D);
every other closure comes back as a list for approval, with the comment it would carry.
The merge bar
Merge when all of:
- Every required context green on the PR's current head SHA.
gh pr checksreports the newest completed run, which can predate the last push — confirm the SHA. Do not count legs. Since #3141 a pull request runs three BC legs (.github/pr-bc-versions.txt: 27.0, 27.5, 28.4), not eight, so a bar phrased as "all 8 legs green" would refuse a legitimate PR or send you hunting for legs that do not exist. The legs are not required contexts anyway — the aggregateBC test matrix passedis, and it fails when any leg of whatever matrix ran fails. The other five versions run onmainviamain-verdict-floor.yml, not on the PR — on a 30-minute cadence and again about ten minutes after a merge burst ends (#3679).tools/ci-wait.pyprints that floor's newest verdict beside the PR's, so a redmaina PR merely inherited is visible before you arm it. git merge-tree --write-tree --messages origin/main origin/<branch>is clean.mergeStateStatus: CLEANonly covers textual conflicts.- The proving test exists, and the corpus-PR condition of the arming list above holds.
One listing per sweep, one verdict per PR you arm: gh pr list --repo <owner>/<repo> --state open --limit 500 --json number,headRefOid,isDraft,mergeStateStatus,statusCheckRollup returns
every open PR's head, merge state and rollup in one call, which orders the sweep and replaces
per-PR run listings; 500 rows returned means the list may be cut, so say so and stop. The
rollup is never the verdict: run tools/ci-wait.py <PR> --timeout 0 for every PR you consider
arming, and never block on it. One pass, one answer, returns at once: 0 green on current head, 1 failed
with the log already fetched, 2 still running (not a verdict, and the ordinary answer on a PR
just opened — leave it for the next sweep, since arming --auto lands a reviewed PR the moment
its checks go green with nobody present), 3 undetermined, 4 blocked with everything green — a
cancelled required context (below), or a required context that produced no check run at all
once every workflow run finished (#2807).
After a merge lands, clear the labels of every issue the PR named (.claude/agents/orchestrator.md
Step 2), with your own dispatch record as the identity list that step requires.
A FAILED verdict names what has reported so far. While other required checks are still running the failing list can grow, and the tool says how many have not reported. Do not scope a diagnosis to those names until it has: reading "1 of 9 required checks failed" as "only BC 27.0 is affected" started a version-specific investigation of what turned out to be eight failing legs.
Never gh run rerun a failed job — it destroys the log permanently. Read
--log-failed first, then push a new commit. An empty --log-failed is a
refusal rather than an empty log — the job's failing step was cancelled, so
there is no failure step to print; fetch it with
gh api repos/<o>/<r>/actions/jobs/<id>/logs --allow-escape-sequences instead
of concluding there is nothing to read (.claude/rules/ci-verdicts.md §3).
A CANCELLED check blocks the merge, with everything green and nothing saying why.
A ruleset satisfies a required check from the newest check run carrying that context name on
the head commit, and cancelled does not satisfy it. cancel-in-progress produces that
conclusion whenever a pull_request event fires without moving the head SHA — edited,
labeled, unlabeled — because the cancelled run's checks then land on the very commit the
ruleset is reading. gh pr checks still shows all green and the merge is still refused as
BLOCKED.
#2726 fixed both halves. The required Tests updated job moved out of pr-check.yml into
require-tests.yml, which has no concurrency block and does not trigger on edited, so no
required context is cancellable on its own commit any more; check_required_contexts.py fails
CI if one becomes so again. And ci-wait.py now returns exit 4 naming the cancelled
context instead of reporting GREEN.
If you still land in this state, re-run just the cancelled run — it clears in under a minute.
That is NOT the forbidden gh run rerun, provided nothing on that commit concluded
failure before the cancellation: a check run can fail on its merits and only then have its
run cancelled, and re-running destroys that log like any other. Check the commit's check runs
first and, if one failed, read its log or take a second run by another route
(ci-verdicts.md §3, §5). Do not reach for --admin: protection is working, the context
genuinely is not satisfied.
Which checks gate is now mostly a property of the file a job lives in (#3165):
.github/workflows/pr-gate.yml is where gating jobs live, pr-check.yml is advisory and
cannot block. Mostly, not entirely — a few pr-gate.yml jobs are deliberately not yet in
the ruleset, tracked as PENDING_REQUIRED_CONTEXTS in check_required_contexts.py because
promoting one early makes ci-wait.py answer exit 3 for everybody. So a red tick from that
file is not proof the merge is blocked. Ask, rather than counting jobs in a file:
gh api repos/StefanMaron/BusinessCentral.AL.Runner/rules/branches/main \
--jq '[.[]|select(.type=="required_status_checks")
|.parameters.required_status_checks[].context]'
Before the split all twelve guards lived in
pr-check.yml and none of them gated, so #3116, #3112 and #3095 each merged with one in a
FAILURE state, and a red closing-reference or CI-skip tick stopped nothing. pr-gate.yml
carries no concurrency block, deliberately — that is the same #2726 rule applied to a
workflow whose jobs ARE the required contexts. Do not read the gating list out of a document;
ask gh api .../rules/branches/main and let the answer be the measurement.
Auto-merge drains the queue — but a drained queue is not a verified one. The "branches must be up to date" protection rule was removed, so arming several PRs lets them merge in sequence without each waiting for a rebase. Use that: review on arrival, arm, move on.
The cost of dropping that rule is that a PR can merge on a green verdict measured against an
older main. A clean textual merge says nothing about semantic conflict — two PRs can each be
green alone and wrong together. So arm freely when PRs touch different files, and when two
touch the same file, still land one and rebase the other with its affected tests re-run
rather than trusting the earlier verdict. git merge-tree only answers the textual question.
Order matters between a corpus PR and the runner PR citing it. Merge the corpus PR first,
then the runner PR — which was measured against that corpus PR's branch head and, after the
merge, resolves master (#3737). There is no pin and no count-baseline line to conflict on any
more; what a second run can still change is the corpus itself, so a runner PR whose verdict
predates a corpus merge is measured against the older corpus. Read the corpus: <sha> (<ref>)
line the legs print before arming, and re-run rather than carrying an old verdict forward.
Merging the CORPUS PR is the direction this paragraph does not cover, and "citing" is the
wrong key there. A runner PR declaring Corpus-NA: is paired with a corpus PR and names it
nowhere, so "does any open runner PR cite this one?" answers empty for a pair that exists.
Before merging a corpus PR, read its own body for the runner issue it was written for and check
that issue for an open PR closing it — the recipe is in al-language-submodule.md §
"Finding the pair from the CORPUS side, where no citation points back" (#4168). Measured on
corpus #350, whose pair #4141 even said in prose that the two were unlinked.
A push restarts the matrix, so a non-blocking finding waits for the gating leg. A reviewer's "worth fixing eventually" item on a PR whose required BC legs are mid-flight costs ~15 minutes of matrix, and that cost is invisible while you are reading a trivial diff. Push it when the legs have reported, not while they run — and never on a PR that is unblocking others, where the delay is multiplied by every PR behind it. Measured twice in one session (#3923, #3927), both times a comment-only change that was correct, verified, and still the wrong moment. "Trivial" describes the diff, never the schedule, and the schedule is what is scarce near a merge.
Expectation-manifest drift is dispatched from here, and only from here. A known-gap entry left behind after its issue closed, or a red main from manifest drift, gets one implementation agent per drift, briefed to carry the entry's key, <CodeunitName>.<Method> from the manifest entry, in both the issue title and the PR title, after gh pr list --state open --search "\"<CodeunitName>.<Method>\" in:title" --json number,title returns no title containing that key; when it returns one, that PR is the fix in flight. Done when exactly one open PR title carries the key. An implementation agent that finds a drift comments and keeps its own task (.claude/agents/impl-agent.md).
Measurement rules
These exist because each was violated at real cost.
- Never conclude from a run that has not finished. A partial local run once produced a three-class failure list; the completed CI run found five failures in two further classes.
- Wall clock lies on a loaded box. With several agents running, identical work measured
1.9s and 3.1s. Use instructions-retired (
perf stat) for anything CPU-bound; it held to ±0.1% across the same runs. - A children-inclusive profile percentage is not a saving. In a JIT-dominated process it measures what is reachable from a call site; deleting the caller moves the cost to the next one. Price a change by removing the work and re-measuring, not by reading a call tree.
- Never compare two configurations across a rebuild. Rebuilding the runner invalidates
the AL-output cache, and a cold run can report a different pass count for reasons unrelated
to your change — 873 vs 925 on the same code in one session. Set the variable through an
override on ONE warm cache instead, and pass a private
--cache <dir>. - Include a control. Convert three classes, leave two untouched, and show the untouched ones flat. That is what makes the deltas believable.
- Do not rank work by the bc-linux container comparison. That tier patches BC's binaries at startup, and filtering by it once hid the single largest cluster in the bucket — 102 tests, worth +93 when fixed.
The corpus must grow with the fixes
The corpus is the only place a claim about BC gets adjudicated by a real service tier, so it should gain a test roughly as often as a fix makes such a claim. If a session merges several fixes and opens no corpus PR, that is a signal to check rather than a sign the work was all infrastructure.
Ask of every PR: does this assert something about what BC does? Runner infrastructure — process configuration, CI plumbing, error handling, caching, parallelism — genuinely asserts nothing about BC and owes nothing upstream. A change to what the runner makes AL code observe almost always does.
That question is the common case rather than the whole rule. The wider one: wherever it is
possible to red-test something with AL tests, that should add tests to the corpus — wherever
the fix can be proven by AL running against a real service tier, it owes an upstream test even
when its claim does not read as a statement about BC. The service-tier clause is the boundary:
runner-only claims are red-testable in AL too, and they stay in tests/runner-extras/.
Nothing about this changes when a PR may merge: the corpus-PR condition of the arming list
above decides.
"The corpus cannot express this" is a claim, and it needs its evidence like any other. It
is sometimes true and the reason is usually structural: corpus tests are compiled from AL
source by the runner, so a defect that only affects precompiled dependency artifacts
cannot be reproduced by a corpus test, which would take the source-compiled path and pass. That
is a legitimate answer. What is not legitimate is reaching for it because writing the upstream
test is slower. When an agent gives that answer, make it name the structural reason — and if
the reason is real, the proving test belongs in tests/runner-extras/ and the PR should say
so explicitly.
Brief implementers to test that claim up front and put the answer in the PR body whichever way
it comes out; it has gone both ways. A defect assumed precompiled-only also reproduced on a
source-compiled table, and the corpus app declares a Base Application dependency, so a corpus
test reaches the precompiled path after all (issue #2518, corpus PR #165, merged green on all 8
legs). Against that, table 2000000001 really is out of reach, sitting in
SystemTables.InternalTables, which NavRecordRef.IsSystemTableAllowedForRecordRefUsage
refuses outright — though note what carries it: a service tier measured the sibling id
2000000071 (corpus PR #153, all eight legs red, withdrawn), and 2000000001 follows by set
membership rather than by its own measurement (issue #2774).
Watch for the shape where a fix closes a BC-behaviour issue with only a runner-local test.
That is the case bc-behavior-tests-go-upstream.md exists to prevent, and it is easiest to
miss on a busy day, because a runner-local test is green and nothing complains.
Settling a claim about BC
Order of evidence, highest first: a corpus test green on a real service tier; BC's own shipped IL; Microsoft's documentation; the name of a codeunit. A name is not evidence.
If no corpus test covers the shape, write one and let the corpus CI adjudicate — minutes, not a local container. If no verdict is available at all, say so plainly and land the runner change with whatever coverage is legitimately available. Never write an unmeasured claim into an issue, a doc table, or a comment as though it were established.
When an agent's measurement falsifies an issue you filed, correct the issue body. Otherwise the next agent starts from the wrong premise — which has happened here.
Environment traps that produce silently wrong answers
grepis a shell function. It rejects-Eand--includewitherror: unknown option '-G'and exits 0 with no output, which reads exactly like "no matches". Usecommand greporrgbefore believing an empty result.export AL_RUNNER_HOOK_CONTEXT=coordinatorin the shell that launchesclaudeif this session grepsAlRunner/**/*.csitself:.claude/hooks/prefer-code-navigation.pyblocks that read in an agent context and stays advisory for a coordinator. It is read from the hook process's environment, so exporting it inside a Bash command does nothing, and a dispatched subagent's ownagent_typestill outranks it (#3707).- Always pass a private
--cache <dir>to runner invocations. The shared~/.cache/al-runneris not keyed on the runner binary, so a concurrent agent's payload can make a fix look like it did nothing. - One hung AL test ends the whole run, reporting a partial count that looks like a
regression. Park the codeunit rather than reaching for
--test. - The MS test company is
CRONUS International Ltd_— underscore, not a period. .mcp.jsonchanges need a session restart. Registering an MCP server mid-session does nothing for that session or its subagents.- When 1Password is locked, commits and pushes both fail. Signing goes through
op-ssh-signandoriginis SSH via the same agent, sogit commithangs waiting for a signature andgit ls-remotefails. With the repo owner's authorization, fall back to unsigned commits: setcommit.gpgsign=false/tag.gpgsign=falsein the repo config (every worktree shares it), and switch pushes to HTTPS withgh auth setup-gitplusgit remote set-url origin https://github.com/StefanMaron/BusinessCentral.AL.Runner.git. Verify withgit ls-remote origin HEADbefore telling anyone it works. Never read a secret file and never try to unlock 1Password yourself. - The network here times out intermittently. Wrap
ghin a retry; a barei/o timeoutis not an answer, and treating one as "no results" corrupts whatever you concluded.
Tooling you should be using
tools/context-pack.py <Name>...— definition + source + call sites, one round trip.tools/lsp-query.py callers|symbol <Name>— exit 2 means the server failed, not "none".mcp__bc-decompiler__*— BC's own code. Contextsbc270…bc284are pre-registered;search_members→memberId→get_decompiled_source/find_callers.compare_symbolsdiffs a method between BC versions, which is how a Cecil rewrite that stopped being reached gets caught.tools/preflight.py --agent-id <AGENT-ID>— the box check, with the identity it needs. Without it thebranch-ownershipcheck can only WARN (#3746). From this checkout it PASSes, having nothing to compare; it refuses from a worktree, so brief agents to run it there.tools/agent-cost.py <tasks-dir>— where a session's agents actually spent their calls. Measured once: 85% of Bash calls were shell read/search and the navigation tools were used 3 times in 3,237 calls. Re-measure rather than assuming it improved.
The ready queue
Read its age once per cycle:
gh issue list --repo StefanMaron/BusinessCentral.AL.Runner --label "status: ready" --state open --limit 500 --search "sort:created-asc" --json number,createdAt
Every cycle summary carries three numbers from it: how many issues are ready, how many were created more than seven days ago, and the number of the oldest; 500 rows returned means the first two are lower bounds, and the summary says so. Done when those three appear in the summary.
The queue is shared, and age is the tie-break. When nothing else orders it — no measured failure
count, no red main behind an issue — claim the oldest ready issue first, and a pool with no
ready filings of its own claims from this same queue, oldest first.
Reporting to the owner
State what was measured and what was assumed, separately. When you got something wrong, say so plainly in a sentence and move on — no ceremony. Give the number that the reasoning actually produces, including when it is worse than hoped: an estimate of "about 9 minutes" that holds beats "under a minute" that does not.
Sister rules
.claude/rules/ is auto-loaded and remains authoritative. The ones this skill leans on most:
ci-verdicts.md, branch-and-pr.md, public-posting-approval.md,
bc-behavior-tests-go-upstream.md, ask-the-corpus-before-claiming-bc-behavior.md,
no-base-app-in-csharp-tests.md, local-test-scope.md, no-git-stash-with-worktrees.md.