feat(work): pipeline edges, spend confirmation, and a visible finish line - #83
Open
jiunbae wants to merge 23 commits into
Open
feat(work): pipeline edges, spend confirmation, and a visible finish line#83jiunbae wants to merge 23 commits into
jiunbae wants to merge 23 commits into
Conversation
`[ticket]`, `[[route]]`, and `[pipeline.*]` are the most structured configuration muxa asks for and the least guessable: nested tables, an ordered array of routes, regexes, placeholder templates. Reading a reference and hand-editing `config.toml` is the friction that stops the feature being used at all. So muxa does here what it already does for tickets — it asks an agent. One headless turn (`ask::one_shot`, the same bridge) turns "cal-* tickets: codex planner, codex implementer, claude reviewer" into TOML. The point of the command is the part it does *not* delegate. `Config` denies unknown fields, so a model's typo written to disk takes the daemon down at its next start. `validate_proposal` parses the answer, compiles every regex, checks each route names a pipeline that exists, and rejects an agent whose `program` is not one of the four allowlisted CLIs — naming the offending value and what is allowed. Nothing is written until it passes and the operator confirms. Merging goes through `toml_edit` and touches only those three keys, so comments and every other section survive verbatim; the command reports which of the three it would replace rather than half-merging a pipeline set. Writing reuses `init::apply::atomic_write`, now shared, so the file is never seen truncated. Verified end to end against a fake resolver: a good proposal writes, `[watch]`/`[ask]`/`# my notes` survive, and the result immediately drives `muxa work up cal-42 --dry-run` into the generated triad. A proposal naming `program = 'vim'` is refused with that reason and the file's checksum is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`muxa work init` printed "asking claude to write the config…" and was already spending the turn. Worse, `--dry-run` reads as "nothing happens" when it skips only the file write: the agent turn — the expensive half — runs either way and bills the same. Before spawning, both commands now name what they are about to run: the agent, the directory it runs in, its permission policy and timeout, and that it is billed like any other turn. Under `--dry-run`, `work init` says outright that the file write is the only thing being skipped, and `work up --help` says ticket lookup still costs a turn on a cache miss. `work init` asks before spending, and a non-interactive caller must pass `--yes` — the same gate the rest of muxa puts in front of an action you cannot take back. Refusing happens before the spawn, not after. Both then report what the turn actually cost, which `AskAnswer` already carried and nothing surfaced. `work up` gets the notice but no prompt: it is the everyday command, the 15-minute cache makes re-runs free, and announcing only on a cache miss keeps the common path quiet. `--no-ticket` spends nothing and says nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The confirmation was yes/no, so the only way to resolve with codex
instead of claude was to remember `--agent` before running. Picking the
agent is already the decision to spend, so it is now one prompt:
Spend one agent turn on…
● claude configured
○ codex
○ Cancel call nothing
The list is derived, not written down: agents the headless bridge can
actually drive, intersected with what is on PATH. `muxa::ask` now names
that set, so it stays one source of truth and a new resolver shows up
here for free.
That is deliberately narrower than the agents muxa can launch into a
pane. The launcher knows gemini, agy, and opencode; the bridge needs a
print mode that reports completion as a fact — an exit code plus a
parseable envelope — and only claude and codex have one it parses today.
Offering an agent that fails after being picked is worse than not
offering it. Checked while writing this: gemini's `-p -o json` is the
right shape but this machine's CLI now refuses to authenticate at all
(Google moved individuals to Antigravity), and `agy` is not installed
here, so neither could be added and verified rather than guessed.
Nothing on PATH is a clear refusal naming what muxa needs, and a
non-interactive run still stops before spawning anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`work up` decided staffing from tmux alone: does this alias have a pane.
muxa has tracked agent state all along — Working, Idle, WaitingInput,
WaitingChoice, Error — and the plan never asked. So three very different
situations read as one identical `= running`: an agent eleven minutes
into the work, one that finished its turn hours ago, and one frozen on a
permission dialog nobody will ever answer.
The plan now carries state from the daemon's registry. An agent blocked
on a person becomes its own step and is never prompted over: a message
typed at an approval dialog is swallowed by the dialog, and the operator
is left believing it was delivered. That also means such a window is not
reported as converged, because it cannot converge until somebody answers
it.
Reading state is best-effort. `work up` does not otherwise need the
daemon and a launch must not fail because the control plane is down; with
no snapshot the plan degrades to the pane-only view it always had.
`--show-prompts` prints the exact text each agent would receive. It was
already in `--dry-run --json`, buried where nobody reads it, which is why
a stale `{{ticket.title}}` could reach a real launch. Seeing the prompt is
the cheapest possible review of a pipeline, and it costs nothing.
`work up` with no --body/--skill/--context now asks for one. muxa keeps
no opinion about what goes in it: "cal-7210 callabo resolve" is opaque
text it carries to every agent verbatim, so invoking a personal skill
stays the operator's business and never becomes muxa's configuration.
Empty is a valid answer, and a non-interactive run is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A first `muxa work up cal-7210` answered with TOML syntax:
Error: no [[route]] matches work id "CAL-7210";
add a catch-all route with match = '.*'
That is only an error for someone who already has a config. For everyone
else it is the first thing muxa ever says to them, and telling a new user
to go hand-edit nested tables is how a feature goes unused.
`muxa work init` is already the conversation that fixes it, so the
unconfigured case now hands over to it and retries once with the config
it wrote. One command, one question, and the work starts — no config file
to prepare beforehand.
Only for a missing route, and only interactively: a script wants a
failure it can read, not a prompt, so non-interactive callers keep the
plain error unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…from
`prepare` shipped without being added to the schema `muxa work init` shows
the model, and the consequence was quiet: the model is told not to invent
keys and it obeyed. A real run produced a config with this in it —
# NOTE: the schema has no `prepare` key (and unknown keys are a hard
# error), so ... or ask for a real `prepare` hook upstream.
— and worked around the gap by telling the implementer agent to create the
workspace by hand. The guardrail did its job; the schema was wrong.
A key the schema omits is a key that cannot be generated, so the two are
now locked together by serializing the config structs and asserting every
field name appears in the schema text. That test immediately found a
second one: `task` had never been documented either.
Found by running the feature against a real ticket rather than by reading
the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…host Two defects a real run surfaced. **A comment changed which section it belonged to.** `muxa work init` spliced its tables wherever `toml_edit` put them, which for this config was between `[dashboard]` and the comment that belonged to it. A comment before a header lives in the *previous* section's span, so `# allow_work_start = true` came back sitting inside `[[route]]` — and uncommenting it there would have been a hard config error, because `route` has no such key. New sections are now pinned past everything already in the file, so nothing is ever spliced above an existing comment. **A unit test asserted a fact about the developer's machine.** The dashboard pane scan always includes the host's own tmux server, so `works.len() == 1` held only while nobody had a muxa-managed work window of their own. One appeared, and the test started failing for a reason that had nothing to do with the change under test. It now selects the work it created instead of counting them, which is what it meant to assert. This would have failed CI on any machine running muxa. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A codex reviewer pane sat two minutes on
› 1. Yes, continue
2. No, quit
Press enter to continue
while muxa reported it `idle` — indistinguishable from an agent that had
finished its turn. `muxa work up` had just learned to surface a blocked
agent instead of prompting over it, and the signal underneath was wrong,
so it never fired.
There was no bundled codex manifest at all, and the pattern style the
other manifests use would not have matched anyway: they anchor a
selection widget to an ASCII `>` cursor, and codex renders U+203A (`›`).
The new manifest matches `[>›❯]`, and its rules were derived from that
real captured pane rather than guessed.
This closes the case where codex runs as `codex`. Two blockers remain for
installs where it runs under `node`, both in muxad and both needing their
own decision rather than a quiet change here:
- `gather_candidates` selects a manifest by `pane_current_command`, which
is `node` for an npm-installed codex. The registry already knows the
pane's agent kind and would be the better key.
- `PaneOwnership::Hooked` skips capture entirely for hook-reporting
agents. That is right once hooks flow and wrong before the session
exists — the startup gate precedes every hook, so no hook can report
it. Codex is not attention-blind the way agy is, so flipping
`hooks_report_attention` would trade one gap for a regression; the fix
is a narrower "hooks have not spoken yet" case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixing the first host dependence in this test exposed a second one underneath it. `GET /api/works` is not a read: it upserts an external item for every pane it discovers, and the pane scan includes the host's real tmux server. So the store file ends up holding a row per work running on the developer's machine, and `records().len() == 1` was only ever true on a machine with none. It now looks for the record it wrote instead of counting rows, which is what both assertions were trying to establish. Worth noting separately: an endpoint spelled GET that writes to durable storage on every call is surprising on its own terms. That is the v2 work model's design to settle, not something to change quietly from here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lementer A pipeline was a *set* of agents, so `work up` always launched all of them at once. Watching a real ticket showed the cost: the reviewer came up beside the implementer and spent its rounds rediscovering that the tree had moved under it — "round 2 handoff 직후 공유 트리에 다른 role:implementer 변경이 덮어써, 코드와 테스트가 다시 불일치". Those rounds bought nothing; the work was never wrong, the target was. `after = ['impl']` makes the difference expressible. An agent with an unmet edge is held back with its own plan step rather than launched, so the window is honestly not converged, and the next `work up` starts it. Omit `after` and everything stays parallel, which is right for work that genuinely is. The edge opens on an *observed* signal, not an inferred one. muxa cannot tell "finished" from "paused" — `idle` means both — so the upstream agent says so with `muxa work done` from its own pane, and muxa records the claim on the work window. The window, not the pane: an agent that has finished has often exited, and the fact has to outlive it. It is a claim, not proof; muxa's job is to make it observable and stop guessing. Edges gate the start only. Re-gating a live agent would tear down work in flight every time an upstream alias was reopened. An edge naming an alias the pipeline does not define, or a cycle, is refused when the pipeline is read. Both fail silently otherwise: the dependent agent simply never launches and `work up` keeps reporting a window that cannot converge without saying why. Verified end to end: the reviewer is held (`⋯ review waiting after impl`), `muxa work done` records `done=impl` on the window, the next run launches the reviewer, and `--undo` puts the edge back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An array of tables with an `after` key buried in one of them is a poor way
to read a dependency graph. The question an operator has before paying for
a run — what starts now, what waits, what runs together — is exactly the
one the file does not answer.
`muxa work up --graph` lays the pipeline out by dependency depth:
── starts immediately ──
● docs codex writer
● impl claude implementer
── waits for depth 0 ──
○ review codex reviewer after impl
── waits for depth 1 ──
○ ship claude releaser after review, docs
Depth is the point: equal depth means parallel, which no amount of reading
the TOML tells you. `ship` sits at depth 2 because it waits on the deepest
of its edges, not the first one listed.
Layout is a repeated relaxation rather than a recursive walk — the sets are
tiny and this cannot blow the stack on a pathological config — and it
assumes validated edges, which `desired_agents` guarantees by refusing
unknown aliases and cycles before anything renders.
Glyphs stay inside Geometric Shapes U+25A0–25CF. The half-filled circles
just past that range fall back to a different font in most terminals and
render at the wrong size.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things a real run made obvious.
**The ticket lookup billed before asking.** `muxa work up cal-7210
--dry-run` printed "one headless claude turn, billed to your account" and
"that lookup cost $0.1367" — and only then asked what the agents should
do. The money was gone before the operator had said anything. `work init`
had been fixed to ask first; `work up` was left announcing on the grounds
that it is the everyday command, and that was the wrong call. The cache
makes re-runs free, so the prompt appears exactly when money would move.
`--dry-run` is no exemption: it skips creating panes, not the turn that
resolves the ticket.
The request prompt now comes first too, since it is free and it is the
last thing the operator can still change once spending starts.
Non-interactive callers get a refusal naming both ways through, `--yes`
and `--no-ticket`, instead of a silent charge.
**The graph and the plan printed as two blocks**, so every agent appeared
twice — once as a shape, once as a row — and neither view was complete.
They are the same thing: what runs when, and where it is now. The plan is
now grouped by dependency depth, and `--graph` is gone because there is
nothing left for it to show. A pipeline with no edges stays a flat list
rather than gaining ceremony it does not need.
now
● impl claude implementer will start
then · after impl
○ review codex reviewer waiting for impl
`UpResult` carries the layout, so the dashboard can draw the same graph
without re-deriving it from config.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
muxa refused to touch a session it had not created, so a `callabo` session the operator already had got a `callabo-2` beside it. That contradicts the model it is built on: one session is one workspace, and this split one workspace across two sessions. The safety that mattered was never "do not touch it". It is that muxa must not *close* a session full of windows somebody else opened. So identity and ownership are now separate facts on the session: an adopted session gets `@muxa_workspace_id` so its work windows are findable, and never `@muxa_managed_workspace`, which is the flag `close_workspace` reads. That call now refuses an adopted session by name and points at closing the work windows instead. A workspace id alone is enough to be listed, so `muxa work list` and `work show` see adopted sessions like any other. Only the destructive path asks about ownership, which is the only place the distinction should matter. Verified against a live tmux: with an unmanaged `cwdtest` session present, `work up` put the window in it instead of creating `cwdtest-2`, the session carries `ws=cwdtest` with an empty managed flag, and `workspace close` refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent gaps kept muxa reporting `idle` for a codex pane sitting on its trust/policy question, which is precisely the one screen codex's own hooks structurally cannot report: it paints before the session that owns the hooks exists. 1. Manifest selection keyed off `pane_current_command`. An npm-installed codex runs as the `node` shim, which names no manifest, so the pane was never even a detection candidate. Select by the registry's agent kind first (`AgentKind::screen_manifest_name`), falling back to the command. The kind only wins where the command still looks like an agent host — a direct manifest match or a known wrapper — so a pane that fell back to a shell still drops out of candidacy and the stop-sweep keeps working. 2. `PaneOwnership::Hooked` skipped capture entirely. Correct in steady state, wrong in the window before the first hook can fire. A hook-reporting row that is still `Idle` within STARTUP_ATTENTION_WINDOW of launch is now merely `AttentionBlind`, so the existing refinement path may promote it to waiting — and only that, since `Working` contributes nothing there and hooks are never raced. Both bounds are tested: the window closes on time, a row that already left `Idle` is untouchable, and a live hook row outside the window still skips capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e top Live reproduction of the codex startup gate showed the previous commit was necessary but not sufficient. Two more links in the chain were broken, and the second is the one that actually swallowed the signal. `gather_candidates` could only name an agent muxa already had a registry row for — but the row is minted by the first hook, and the gate paints before any hook exists, so during the gate there is no row to consult. Fall back to `discovery::discover_from_panes`, which already walks the process tree for wrapper panes and finds the real `codex` binary under the `node` shim. Registry first (it knows what muxa launched), process tree second, foreground command last. `prepare_capture` then discarded what it found. tmux pads a capture out to the full pane height, and the gate occupies the first nine lines of a 50-row pane, so taking the last 40 lines verbatim kept nothing but blank padding and the pane classified as unknown. Drop trailing blanks before slicing: they carry no signal for any rule, and without them the tail means "the last N lines that exist" for scrolling transcripts and top-anchored screens alike. Verified live: with the gate on screen, `muxa status` now shows the pane as `input` and the row reads waiting_input / "codex is waiting". The regression test is built from that real capture, and a companion test pins that a long transcript still keeps its end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`muxa work up` stamps `@muxa_agent_role` and `@muxa_agent_alias` on every pane it launches, but the collaboration room only knew about identities an agent registered for itself with `muxa identity set` — which a pipeline agent never does, and could not do before its first turn anyway. So `role:implementer` matched nobody and the reviewer's handoff was dropped with "no eligible peer", leaving the pair stalled while the work looked finished. `PANE_FMT` had been asking tmux for `@muxa_agent_role` all along and the parser threw the column away, so the fix is mostly to stop discarding it: keep it (plus the alias, appended to the format) on `PaneInfo`, seed `participants_from` with both, and stop having `enrich_participants` clear what it did not set. That splits identity into two claims of different kinds. A registered identity is the agent's statement about itself and stays bound to the exact agent generation that made it, so a later process on the same pane inherits nothing. Pane metadata is the launcher's declaration about the slot — "this pane is the pipeline's reviewer" — which remains true for whoever occupies it. Registration therefore overrides pane metadata (wholesale, so an agent can shed a launched role), and its absence leaves the launcher's claim standing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`muxa work up` read the work's completion set to decide what to unblock, then dropped it. So a pipeline that had converged and one that had stalled rendered identically — every pane `idle` — and a finished review sat unnoticed for two hours because nothing on screen distinguished "the reviewer is done" from "the reviewer has not been asked anything". Carry the set onto `UpResult` and render a reported agent as `done`. Gated on the agent actually being at rest: one that reported and was then re-prompted is working again, and showing `done` over live work is a lie the operator would act on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`work list` printed one `key=value` line per work, so the eye had to parse it, and the one fact that mattered most was missing entirely: whether the pipeline had finished. Render it as a themed table — work, workspace, agents, done, cwd — with the stage column appearing only when something is staged, and cwd shortened to `~/…` so the column spends its width on the part that differs between rows. `--json` is unchanged. `muxa watch` had the same blind spot at the work row. It now carries the completion ratio, read from `@muxa_work_done` and the pipeline aliases, which `PANE_FMT` now retains alongside the role and alias it already collected — so no extra tmux round trip per refresh. Both count aliased panes only. `@muxa_work_done` records aliases, so a pane the operator split by hand could never appear in it, and counting it would show a converged pair as 2/3 forever. A window with no aliases at all has no ratio rather than `0/1`: it was never a pipeline and nothing there was ever asked to report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`muxa watch` could spawn a single agent into a work window but had no way to run a pipeline, so starting one meant leaving the TUI for a shell. `w` opens a one-field composer for a work id and hands the operator to a new tmux window running `muxa work up <id>`. Watch deliberately does not run the pipeline itself. `work up` resolves a ticket, which costs money, and the CLI already owns the flow that says so and waits for a yes. Re-implementing that inside the TUI would put the decision to spend the operator's money in two places, and the second one would be the one nobody reviewed. So the TUI's job ends at putting the operator in front of the real thing. The work id reaches a shell, and `normalize_work_id` rejects only tabs, newlines and over-long input — a quote or a semicolon passes. Both it and the binary path are single-quote escaped, and a test pins that an id built to break out arrives as one word. `w` joins `n` on the help line rather than taking one of its own: the overlay is clipped by terminal height, and an added row pushes the last binding off the bottom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The completion badge only reached the flat work row. `[watch] view` defaults
to the tree, so the surface most people actually look at still could not
tell a converged pipeline from a stalled one — which was the whole point of
adding it.
Compute it once in `topology`, where the panes are already being folded into
windows, and carry it on `WindowNode`. The tree label renders it, and the
flat row now defers to the same function instead of restating the counting
rule, so the two views cannot drift apart on whether a pipeline finished.
`PaneNode` gains the pipeline alias for the same reason the window needs it:
completion is counted over aliases, and a hand-split pane has none.
Verified against a live run: the daemon emits
`completion = {done: 0, total: 1}` for a work window whose one aliased pane
has not reported yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tree labelled every pane with its agent CLI, which stops distinguishing anything the moment one work runs two of the same kind: a pair whose implementer and reviewer are both claude rendered as two identical rows. Use the alias the launcher already stamped on the pane — `impl`, `review` — and fall back to the CLI, then the raw command, for panes nobody aliased. The CLI stays visible on the row and in the inspector, so this trades a name that repeats for one that identifies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PaneInfo` gained `agent_role`, `agent_alias` and `work_done` on this branch; `registration_pane` arrived on main while the branch was out. The rebase produced a tree where neither side was wrong and the combination did not compile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns
muxa work upinto a pipeline that can actually finish: agents wait oneach other, hand off through commits, and — the part that was missing — say so
where an operator can see it.
Why
A run of the pair pipeline (implementer → reviewer) produced a good review that
then sat unnoticed for two hours. Nothing was broken in a way that showed up:
the review completed, both panes went idle, and
muxa work uprendered exactlywhat it renders for a pipeline that stalled. Several separate defects fed into
that, and each is a commit here.
What it adds
Edges.
after = ['impl']holds an agent until its upstream reportsmuxa work done, so a reviewer never reads a tree that is still moving. Thegraph is cycle-checked and drawn in the plan output, because the TOML cannot be
read as one.
Spend confirmation. A ticket lookup costs a headless turn.
work upnownames what it will call and waits for a yes before spending,
--dry-runincluded — a dry run that writes config still bills on the next real run.
muxa work init. Describes a pipeline in your own words and has an agentwrite the
[ticket]/[[route]]/[pipeline.*]config, with a drift test thatfails if a config field is missing from the schema the agent is handed.
Completion, everywhere.
work listrenders as a table with aDONEcolumn;
watchshows the ratio on the work row and names panes by theirpipeline alias (
impl,review) rather than by CLI, which stopsdistinguishing anything when one work runs two claudes.
wopens a composerand hands you to a window running
muxa work up, so the confirmation flowstays in one place rather than being rebuilt inside a TUI.
Detection fixes
Codex's trust gate paints before the session that owns its hooks exists, so no
hook can report it. Four separate things had to be true for muxa to see it, and
none were: the manifest was selected by
pane_current_command(an npm codex isnode), a hook-owned pane skipped capture entirely, a pane with no registry rowcould not be identified at all, and
prepare_capturesliced the tail of a50-row pane whose only content was in the first nine lines.
role:routing was also silently dropping messages.work upstamps@muxa_agent_roleon every pane it launches, but the collaboration room onlyknew about identities an agent registered for itself — which a pipeline agent
never does.
PANE_FMThad been fetching that option all along and the parserthrew it away.
Verification
Ran end to end against a real Linear ticket, from an empty workspace:
w→spend gate →
callabo-set ws create→ implementer commits and reports →reviewer launches on the fixed commit → findings handed over → applied and
committed → re-review returns
converged→ both report done →2/2.The first such run needed three manual rescues. The last one needed none.
cargo fmt --all -- --check,cargo clippy --workspace --all-targets -D warnings, and 1579 tests pass on this branch rebased onto main.One commit from the branch was dropped as empty during that rebase —
preparesupport landed independently via #82, identically enough that git found nothing
left to apply.
🤖 Generated with Claude Code