v0.38.0
Changed
- Where a question could be answered by doing the thing, the tool now does the thing
(#650/#653).cerrar-cicloclassified the state of a finished branch correctly and then
printed a command for the user to copy and run. Every state it detects now carries an action
the router executes directly — save the handoff, open the PR, run the gate, clean up, reopen
or discard a closed PR. Merging stays a human decision, and so does a genuine fork (save a
handoff or not; reopen or discard); everything else it can safely take, it takes. This
reverses a documented design choice on purpose: printing a command is not help, it is the work
handed back to someone who asked the tool to do it. - Prompts and hints are phrased in the user's intent, not in tool jargon (#495). A survey
found nine places where a live session asked a BI professional to adjudicate git semantics or
paste a command. Seven are fixed here: the doctor's ghost-worktree hint no longer names a git
plumbing command (the fix already did it safely — only the wording was wrong), the expert
config's "no role matched" hint no longer prints raw PowerShell splat syntax, and two prompts
in the work driver now ask what the human actually decides ("this already shipped, want me to
clean up?") instead of restating branch and PR state as a puzzle. The eighth and largest is
thecerrar-ciclochange above; it needed its own issue because it reverses a design choice
rather than a sentence.
Added
-
An opt-in second reviewer for autonomous runs, and it is now visible where the decision is
made (#645, #646). The review gate can embed a disk-checkable marker when an external review
is recorded, and re-verify it at gate time — a marker that does not verify is dropped from the
evidence as if it had never been posted, so "someone reviewed this" cannot rest on a claim the
gate is unable to check. It is opt-in: the CI-bot fallback stays the default and callers that
do not ask for the stricter path are untouched.The option had been living only in reference docs, which nobody setting up an autonomous run
reads first, so the choice moved to/board expert configwhere review strictness is actually
decided. It refuses to recordtruewhen the plugin providing that reviewer is not installed
— reporting why, rather than writing the preference and silently falling back — and the run's
brief now renders the branch that was actually configured instead of mentioning the option
regardless. The earlier "not headless-invocable" finding turned out to be a naming bug, not an
infrastructure limit (#637). -
Grouping related issues into one PR is now the default posture, not the exception it had to
argue for (#662). The machinery to work several issues through one branch, one PR, one gate
and one merge shipped with #633 and works. What was missing is that nothing ever pointed at it:
inverbs-work.md, one PR per issue sat at step 5 marked MANDATORY, and-StartGroupwas
introduced at 4b as the narrow exception for "several SMALL, SEQUENTIAL sub-issues of the same
epic". Read in order — which is how an agent reads a contract — the default was N PRs for N
issues, and grouping was the deviation you had to justify. So a user who wanted fewer, larger
PRs had to say so every session, and did.The cost being paid is not the code. It is the per-PR cycle: one review-gate run against the
subscription quota, asecond-opinionround whenever no real reviewer shows up, and a merge
confirmation that needs the user's attention. That is charged once per PR regardless of how
small the issue was, so across a board of related issues it dominates the work itself.Three things changed, and only the third actually closes it:
- The contract. A new step 3b decides the PR shape BEFORE anything is started: when the
chosen issues overlap they go in one PR, and one-per-issue is what now needs a reason —
independent risk that should be able to fail review alone, or a separate approver. Step 5
still makes the PR + gate mandatory; what it no longer implies is one PR per issue. - The arithmetic, at the moment it is paid. The pending list names the groups it found,
the evidence for each, and what grouping would remove in review rounds. Evidence is only
ever something it can name: two pending issues that name the SAME FILE OF THIS REPO
(matched againstgit ls-files, so a grouping can never rest on a filename the tool
imagined), or a shared board Area. If it cannot say why two issues belong together it does
not suggest them. - A per-repo standing answer.
-PreferGroupedPRs on|off|autorecords the decision in
.agentic-board/config.json— versioned alongsideroles.json, because how a team shapes
its PRs is a team decision and not machine state. Without this the preference lives in the
operator's memory and is re-applied by hand every session, which is the actual complaint.
Recording it needs no GitHub token: it runs above the auth guard, since a local decision
has no business demanding a PAT.
Two limits are deliberate. A group is capped at 4 issues: run against the real 56-item
board, uncapped grouping cheerfully proposed eight issues in one PR — far past the 600-line /
20-file threshold the review gate itself warns about, trading a cost the user pays knowingly
for one they do not. The overflow is never dropped silently; it is named as a second batch.
And the offer shows the five biggest savings and counts the rest, because twelve groups is a
wall, not an offer.offis a real answer, not an absence: a repo that asked for one PR per issue gets the offer
suppressed entirely, and a config file that cannot be parsed is reported rather than read as
"no preference recorded".onis bounded in the other direction: it never invents a group out
of issues that share nothing — there is no honest way to batch two unrelated issues — and when
nothing overlaps it SAYS so, because silence there is indistinguishable fromautohaving
nothing to report, and the repo's standing preference would look ignored.A group also never spans two repositories. A board holding several repos is ordinary here (it
is what #523 is about), and a single PR lives in ONE repo:Closes #ncloses an issue of that
repo and nothing else, and-StartGroupputs the whole batch on one branch in one checkout.
An area shared across repos would therefore have produced a batch that CANNOT be finished — it
would move every issue to In Progress, post claims on all of them, and strand the foreign ones
with no PR able to close them. Every signal is computed per repo, and file evidence applies
only to the repo the checkout actually is, since the file list comes from its own
git ls-files: a foreign issue namingBoard-Work.ps1is naming a different file.Scoping is per repo all the way down, including the set that remembers which issues are
already in a group. That set is keyed on the issue number, and issue numbers are unique inside
a repository and nowhere else — kept board-wide it letowner/alpha#10lockowner/beta#10
out of every group, silently, and only on the mixed boards the partitioning was added to
serve. And the repo NAME is read through one accessor, because this script carries two item
shapes that disagree: the board listing returnscontent.repositoryas a string while the
GraphQL start path nests it undernameWithOwner. Stringifying the second would have given
every repo the same bucket name and merged the board back into one group — the partitioning
failing while looking like it works.The "next step" line obeys the same boundary. It offers the biggest group OF THIS REPO rather
than the biggest group on the board — a batch the current folder cannot open a PR for is not a
next step — and when every group belongs elsewhere it names those repos instead of proposing
one anyway.Running the listing OUTSIDE a clone keeps working, which the offer nearly broke: resolving
"which repo am I" throws when there is noorigin, and an unguarded call killed the listing
after printing it — over a feature that is only ever an offer. It is guarded now, and with
no repo the file signal simply does not apply: the same board reports 12 saved review rounds
from Area evidence alone instead of 24. Degrading is the honest outcome; a filename cannot be
evidence when the file list was never read.Seven defects came out of the external review rounds and are fixed here rather than filed. The
config WRITER carried the same PowerShell trap the reader had already been fixed for:-is [pscustomobject]is true for every value, because PowerShell wraps everything in a PSObject,
so a file containing"just a string"was accepted as an object and its own.Lengthproperty
written back as a config key. Both call sites now share oneTest-IsJsonObject, since keeping
a copy each is exactly how one of them kept the bug after the other was fixed. The preference
command sat below the GitHub token guard, so a purely local write threw on a machine with no
PAT. Andonwas documented as "always group" while only ever changing the closing sentence —
the words now match what the code does. - The contract. A new step 3b decides the PR shape BEFORE anything is started: when the
Fixed
-
The review gate passed a PR whose only reviewer had said it could not review it (#651).
Copilot with no quota does not stay silent — it submits a COMMENTED review whose body reads
"unable to review ... reached their quota limit". That is a review object bound to the current
head, soGet-ReviewEvidencecounted it,evidence.reviewedcame out true, and the gate printed
GATE PASSED/ exit 0 naming as reviewer a bot that had just said it never looked. The exact
failure #510 exists to close, reopened by the one reviewer most likely to be the only one on the
PR — and worse than the original, because a caller reading only the exit code saw a clean pass.The detection was already there and already correct:
Test-CopilotUnavailableReviewrecognised
the refusal, but its verdict was only ever used to arm the per-account cooldown marker, never
subtracted from the evidence. The issue guessed the cause was the$copilotRequestedflag
gating that call; reading the executing path showed the flag only gates the marker write — the
evidence count never consulted the refusal at all, requested or not.A refusal is an answer, not a review, and the two now go to different places. It ends the
review WAIT (Test-ReviewAnswerArrived) — without that, closing the evidence hole would have
traded a false pass for a guaranteed stall, since a ten-second "no quota" answer would have left
the gate waiting the full timeout for a review that was never coming. And it does not satisfy
the GATE: the verdict routes to exit 2 and says why, because the review list printed directly
above it shows a Copilot review and a bare "0 reviews" would read as a bug in the gate rather
than the truth about the PR. The check is scoped to the bot by login and body, so a human
review whose prose happens to say "not available" is never dropped — that would be a worse bug
than the one being closed.refusedjoinsstaleas a named reason on the evidence object.Review round 1 caught the cost of that reuse:
Test-CopilotUnavailableReview's phrase list was
loose — it matched a barenot available,isn't availableorno seatsanywhere in the body.
Harmless while the verdict only decided whether to re-request Copilot next time (a false positive
cost one skipped request); once the same verdict REMOVES evidence, a substantive Copilot review
saying "that helper is not available in v2" would have been discarded and a genuinely reviewed PR
reported as unreviewed. The pattern now has to be about the reviewer being unable to review or
out of quota, and it is pinned by tests in both directions.Round 2 showed phrase precision alone could not do it, and this repo is the proof: its own
subject matter is Copilot quota, so a REAL review of this very file would say "returns 429 when
the user has reached their quota" or "handle the case where no seats are available" and be thrown
away. The words are identical; only the shape differs. The rule is now two-tier — GitHub's
machine sentences match at any length, an exhaustion phrase only inside a body short enough to
BE a notice rather than a review. Round 2 also moved the refusal partition to cover reviews of
EVERY commit: an old refusal used to land instale, which drove the message "there are reviews
of earlier commits — you pushed after someone reviewed", said about a commit nobody reviewed.
Refusals are now out ofstaleentirely.Round 3 closed the last two ways the widened blast radius could still eat a real review, both of
them likeliest in this repo of all repos. The machine-sentence tier is now ANCHORED to the start
of the body: unanchored it ignored length, so a substantive review that merely QUOTED the sentence
— the kind of review this very file attracts — was discarded whole. And "is this the bot?" was a
substring test on the login, so a human calledacme-copilotwas treated as Copilot and a short
review of theirs could be dropped; it now matches the logins GitHub actually uses. Round 3 also
caught one of the new tests passing vacuously (a 900-char body made the length guard short-circuit
before the phrase check ran) and claimed a miscount in the self-certification filter that turned
out not to exist — the author filter runs before the partition, and there is now a test pinning
that ordering rather than an argument about it.Round 4 finished the separation the earlier rounds had only half made. The inability phrases
(cannot review, "can't review",could not review) were still in the length-guarded tier, and
they are ordinary things for a reviewer to say about the CODE — "I can't review binary files
here" — so a short review saying one was discarded. They moved into the anchored tier, where they
count only when the message OPENS with them; the length-guarded tier is now exhaustion only, a
resource actually running out. The same round-3 login fix was also applied to the silence-cooldown
branch, which had been left on the old substring match.Verified end to end on the PR that carried the fix, which drew the refusal for real: with three
Copilot quota answers on record and no other reviewer, the gate printed GATE SIN REVISAR and
exited 2 — the state that used to print GATE PASSED and exit 0. -
A worktree the doctor could not see was quietly costing a git process every 30 minutes
(#618). The ghost check trusted git's ownprunablemarker, which only ever means "metadata
present, directory gone". The inverse never had a name: the directory survives under
.claude/worktrees/with a broken.gitlink, git has already pruned its metadata, and the
worktree therefore never appears ingit worktree listat all. Never listed → never a record →
neverprunable→ invisible.Invisible but not inert. The agent host keeps those entries in a machine-wide registry and
retries pruning them on a timer, so a handful of them can accumulate hundreds of failed git
invocations over a week. Where the repos live on a mechanical disk, that is felt.Worktree-Ghosts.ps1gives the four states names —ok,prunable,orphan-empty,
orphan-content— and onlyorphan-emptyis ever removed without asking. A new SessionStart
hook sweeps those (startup and resume, never compact, where the worktrees are still in use);
the doctor reports the rest, including registry entries pointing at other repos, since the
retry loop is machine-wide and no single per-repo invocation would ever have seen them.The removal is a non-recursive delete: .NET refuses to delete a directory that is not
empty, so the filesystem — not a check this code performs and then trusts — is what guarantees
no work is lost. A file appearing between the check and the delete costs nothing; the delete
simply fails.Two bugs shipped past a fully green pure test suite and were caught only by running it against
a real repo, which is whyWorktree-Ghosts.Integration.Tests.ps1now exists alongside the pure
one. Comparing absolute paths looked obvious and was wrong — git prints the path it recorded
while the filesystem returns whatever alias you walked in through (8.3 short name, junction,
subst drive), so a live worktree was classified as an orphan; matching by name within the
managed directory sidesteps the aliased prefix entirely. And[string]$Porcelainleft unbound
arrives as'', never$null, so the "should I call git?" guard never fired and the known-set
stayed empty — which orphaned every worktree in the repo.