Repository navigation
Releases: sdsrss/moa-skill
Release list
v1.11.0 — dry-run checks model slugs
Upgrading — dry-run now makes one network call
dry-run used to touch the network zero times. It now issues a single free, unauthenticated
GET https://openrouter.ai/api/v1/models to check your model slugs. Nothing else about the run
changed: no new spend, no new failure mode, same exit codes. (The one cosmetic difference: building
the HTTP opener makes the pre-existing [proxy] detected env proxy line appear on stderr, where
dry-run previously never built one.)
- What you must do: nothing, if that call is fine in your environment.
- If it is not — air-gapped, egress-filtered, or you simply do not want the call — pass
--no-model-check. That is the complete opt-out; the rest ofdry-runis unaffected. - What you will see if the call cannot complete: one line saying the check was skipped, and the
dry run proceeds. A refused connection returns in milliseconds; a blackholed one burns the 5s
socket timeout. That 5s does not bound DNS:socket.getaddrinfois outside urllib's timeout,
so a resolver that silently drops the query adds the system resolver's own timeout on top — 20s on
one machine whose resolver could not be reached, where the same command on 1.10.0 took 0.1s. That
figure is the operating system's resolver timeout, not something this code sets, so treat it as an
example rather than a bound. If your egress filters DNS rather than refusing connections, use
--no-model-check. - Programmatic callers:
dry_run()itself defaults tomodel_check=False, so importing and
calling it never reaches the network. Only the CLI opts in.
Added
-
dry-runnow checks your model slugs against OpenRouter's live list. The README has told
people "model IDs churn fast; verify once withdry-run" since the beginning, anddry-run
verified nothing — it printed whatever slug the config contained. It now fetches
GET /api/v1/models(free, and noAuthorizationheader: the endpoint does not need a key,
so none is sent) and marks each comparable api linkOK/UNKNOWN/MISSING. A retired or mistyped
slug is visible before the run starts, instead of surfacing as a 400 on one seat after the other
seats have already been billed.Links it cannot honestly compare are marked
skip, never guessed at: cli and subagent seats do
not use OpenRouter slugs at all, and a custombase_urlor a non-openrouterprotocolhas its
own model namespace — judging those against OpenRouter's list would be all false alarms, which is
worse than not checking.Which links those are is decided exactly the way
resolve_channeldecides it: the main link by the
member's ownchannel, a fallback by the fallback's ownchannel(absent means api), never by
the value a fallback inherits from its member. Getting that backwards is what v1.7.0's A1 did — it
judged the channel off the merged view and refused legal configs, and v1.7.1 shipped the same day to
undo it. Here the same mistake fails the other way: a cli seat whose fallback omitschannelreally
does run as an api link, and judging it off the merge would silently mark itskip— exactly the
case this feature exists for, since that link sends the member's auggie-side model name to
OpenRouter. The merged view is still what supplies the link's model, protocol andbase_url.The check is fail-soft: offline, blocked at the egress, endpoint moved or reshaped, it prints
one line and the dry run continues with its exit code unchanged — that now includes the judging
step, not just the fetch, so a config field of an unexpected type degrades the check instead of
taking down the dry run. The socket timeout is 5s (a refused connection fails in milliseconds, a
blackholed one burns it); DNS resolution is not covered by it.dry-runis the commandSKILL.md
has the arbiter run in front of the user, which is why the bound matters.--no-model-checkturns it off entirely, and the
dry_run()function defaults to off so that importing it never reaches the network — the
decision to make that GET belongs to the CLI boundary, and the test suite stays offline.
Fixed
-
An api link with no
modelnow says so.call_modelindexedcfg["model"]directly, so the
seat died on aKeyErrorthat_dispatch_channelscaught and reported verbatim: the user's
errorfield read'model' [unknown], which names neither the seat's problem nor its fix, and
theunknownclass polluted the error tableSKILL.mdteaches the arbiter to read. It now raises
the samePermanentErrorshape the cli branch uses when its binary is missing fromPATH
(err_class=startup, with a hint), because both are the same thing: a link whose precondition is
absent, which cannot be attempted at all.No new rejection.
validate_configstill accepts an api member without a model — that is
pinned bytest_validate_config_accepts_valid, and adding the gate a reviewer proposed breaks 22
tests, 4 of themaccepts_*contract tests. A single-model OpenAI-compatible gateway behind
base_urlis also a legitimate reason to omit it. The link still falls through to the next
fallback exactly as before; only the message and the class changed. Rationale is recorded in
tasks/specs/model-preflight.mdso the gate is not proposed a third time.
Tests 372 → 427.
v1.10.0 — options defaults layer
Upgrading — only if your options: block is not complete
If every one of timeout_seconds / max_tokens_member / min_successful_members /
grace_seconds is set in your config (the shipped assets/config.example.yaml sets all four),
nothing changes for you and you can stop reading: dry-run output is byte-identical to 1.9.0.
If any of them is missing, or is written with an empty value, this release changes what happens.
Before, such a config was accepted by validation and then died at dispatch with a raw traceback.
Now the missing keys take documented defaults and the run proceeds and bills — max_tokens_member
is a per-call spend ceiling and timeout_seconds is a per-fallback-link wall clock, so a config that
previously could not start now can spend money.
- What you must do: nothing, if the defaults suit you. To keep full control, write the four keys
explicitly into youroptions:block, copyingassets/config.example.yaml. - How you will notice: the script prints one
[options]line to stderr naming every key it
defaulted and the value it used. It appears once per run, and never for a complete config. - How to revert: pin 1.9.0. There is no flag to restore the old behaviour, because the old
behaviour was a crash.
Fixed
-
A half-written
options:block passed validation and then crashed at dispatch. Both numeric
validators invalidate_configstate in their own comments that an unset key is legal because it
"uses the default" — and four accept-tests pin that contract (27 test sites passoptions: {}as
filler). No default layer ever existed. The same contract therefore broke differently on each
half of the key set, and the two halves differ in how much they cost you:timeout_secondsandmax_tokens_memberwere read with a bare subscript. A missing key
raisedKeyError: 'timeout_seconds'at the first dispatch — loud, immediate, before any
request went out. A key written blank was worse in a quieter way: the subscript found it and
returnedNone, which travelled into the socket timeout and into themax_tokenspayload and
the truncation-retry arithmetic.grace_secondsandmin_successful_memberswere read with.get(key, literal), which falls
back only when the key is absent; a key written blank is an explicitNoneand went
straight through.min_successful_members:then died incmd_generate'smin(None, …)before
dispatch, costing nothing.grace_seconds:is the expensive one: the deadline it feeds is only
computed once quorum is reached and stragglers are still pending, so the same config can work
one day and, on a slower day, die attime.monotonic() + Noneafter several seats have already
answered and been billed, with part of the artifact set already on disk.
There is now one source:
DEFAULT_OPTIONSplus an_opt()reader used at all six call sites.
Missing keys and blank keys are treated alike. The test isis Nonerather thanor— not a
behaviour repair (the old.get(key, literal)also passed an explicit0through) but a guard
against writing the obvious thing later:grace_seconds: 0(wait for nobody) and
min_successful_members: 0(no floor) are both values the validator explicitly admits, andor
would silently promote them to 30 and 2.The defaults are the shipped example's values — with one deliberate exception.
grace_seconds
stays at 30, not the example's 90; that split is the documented v1.6.0 back-compat contract,
recorded inSKILL.mdand inconfig.example.yaml's own comment. A test now reads the shipped
example and asserts the three tracking keys match it exactly and thatgrace_secondsis the
documented 30-vs-90 exception, so neither side can drift into the other.This closes both of the
Known, not fixed hereitems recorded under 1.8.0 below. -
--modelsbypassed the named config error and raised a rawTypeError.main()runs
resolve_config→apply_custom_committee→validate_config, so--modelsreaches
dict(cfg)before anything has checked the config's shape. An empty YAML file parses toNone,
givingTypeError: 'NoneType' object is not iterable— while the same config without
--modelsproducedvalidate_config's named[config]message. One door, two treatments; the
same asymmetry ISSUE-005 was opened for.apply_custom_committeenow returns a non-mapping
config untouched so the existing named error is the one users see on both paths. No second
message was added, and the tests assert both paths produce that same sentence.
Added
- One
[options]line on stderr when a key was defaulted. It names each unset key, the value
substituted, what that value governs (spend ceiling / wall clock / abort floor / grace window),
how to pin it, and that 1.9.0 and earlier would have crashed here instead of calling out. It is
emitted once per run frommain(), after validation and before any dispatch, so it covers
generate/refine/discuss-*/dry-runin one place and cannot interleave with worker
threads. A complete config never triggers it.
Tests 355 → 372. Coverage was measured by mutation rather than asserted: 19 reverts of the changed
lines, 18 caught by the suite. The single survivor is the (opts or {}) guard inside _opt, which
validate_config makes unreachable — defensive, not a tested path. An independent empty-context
pre-ship review (tasks/preship-review-unreleased.md) is what found the first version of this net:
it missed the two cmd_refine call sites entirely, so both could be reverted line-for-line with the
suite still green. Those two tests are now here, as is a gate tying DEFAULT_OPTIONS to the shipped
example.
v1.9.0 — per-seat usage ledger: wasted_tokens/wasted_members, correct this time
Closes ISSUE-012 — the per-seat usage ledger that tasks/specs/degradation-budget.md has been
carrying as "the correct way to do what 1.7.0 withdrew". Mostly additive: new fields, no new
rejection, and two deliberate narrowings of existing readings (billed_members and
billed_calls, below).
Added
-
token_usage.wasted_tokens/wasted_membersare back, and this time they are right. 1.7.0
shipped them and withdrew them the same day because pre-ship review showed they were wrong in
both directions at once: the single largest consumer — a reasoning model's truncation retries,
billingmax_tokens3000 → 6000 → 12000 for 21,300 tokens — lost itsusageinside
call_model's retry loop and reported 0; and a provider that omitsusageproduced an
all-zero but truthy dict, counting a seat that spent nothing aswasted_members: 1.The root cause was neither field.
usagetravelled in local variables along the successful
return path, which makes every exception path a discard point — and patching them one at a time
does not converge (round 1 patchedcall_with_json_repair; round 3 found the same hole one level
down incall_model). It is now a ledger object: every billed response is recorded the moment
it arrives, so however the stack unwinds, the entry is already made. Callers read it once at the
end instead of threading a value back up.Recording early is necessary but not sufficient — pre-ship review caught the follow-on: the ledger
lives in the worker thread's frame, while a seat abandoned at the grace window is judged in the
main thread, which could not reach it. The stack had not unwound; the ledger was alive and simply
unreachable, so an 18,000-token seat was reported as zero waste. Each seat's ledger is now published
for the abandonment path to read — but only what has already landed by then. A single long call
still in flight when the window expires is still not counted, which is why the figure below is a
lower bound rather than a total.wasted_tokenscovers both a fully failed seat's entire spend and the failed links of a seat that
succeeded after degrading, and it is computed per seat and clamped per seat so that one
artifact with an inconsistent shape cannot cancel out another seat's real waste. It is not
folded intototal_tokens, which keeps its existing meaning — "what the opinions cost" — because
the README's cost multiple is read against it.wasted_memberscounts only seats that were billed
and produced nothing; a seat that degraded and then succeeded is not one, though its burned links
are inwasted_tokens. Subscription seats (CH1, CH2 codex) have an empty ledger and appear in
neither. Both fields are reported for refine rounds too (stats.r<N>.json).Report it as a lower bound. True spend is at least
total_tokens + wasted_tokens. A seat
abandoned at thegrace_secondsboundary keeps running in the background and keeps billing; its
ledger is read at the moment of abandonment, so anything it spends afterwards is not counted. An
artifact written by an older version has no ledger at all and contributes only what it
self-reports. A hand-written CH1 artifact contributes nothing even if it self-reports tokens —
CH1 runs on a subscription, so those tokens are not money, and counting them would push the
figure in the other direction. -
Per-seat artifacts gain
usage_total— everything that seat was billed, across every fallback
link and retry — alongside the existingusage, which still records only the link that produced
the opinion. Present on every per-seat artifact this version writes — member files, seats skipped at the
grace window,--injected CH1 seats, discussion-turn envelopes andblindvote_<seat>.json(the
closing blind vote is itself a billed call and carries its own ledger;--injected seats are
always zero, because CH1 runs on a subscription).
Fixed
-
billed_membersno longer counts seats that were never billed. It filtered on the truthiness
of theusagedict; an all-zero dict from a provider that omits usage is truthy. It now filters on
the token count — any ofprompt_tokens/completion_tokens/total_tokensbeing positive,
the same rule the ledger uses. Deliberately nottotal_tokensalone:base_urlaccepts any
OpenAI-compatible endpoint (vLLM, LiteLLM, a local gateway), and a response carrying only
prompt/completion counts would otherwise be judged "never billed" and have its real spend dropped
from the aggregate along with it.billed_callsindiscuss_stats.jsonhad the identical defect and gets the identical fix, so
two existing readings change meaning in this release, both in the same direction: an all-zero
usage dict no longer counts as a billed call or a billed member. -
Four
raisestatements insideexceptblocks now chain withfrom, so the underlying
HTTPError/TimeoutExpired/ValueErrorsurvives into the traceback instead of being replaced
by the translated error. Found by runningruff --select B904over the tree; see the note below on
why no lint gate ships with it.
Note on linting
A lint gate was evaluated for CI and deliberately not added.
Measured with ruff 0.14.2 --isolated --no-cache --select E4,E7,E9,F (the classic default set) over
skills/, at the point v1.8.0 was tagged. It reported 6 findings in moa.py: one placeholder-free
f-string and five deliberate semicolons. Widening to B904 added 4 more. Those two rules are the
only ones that carried signal, and both are fixed above, so they now report zero.
Everything else the wider rule set surfaces is house style: E501 line-too-long and RUF00x
ambiguous-unicode, in the several hundreds and the couple of dozen respectively. No exact count is
quoted here on purpose — both grow with every Chinese comment, so any number written down goes stale
by the next commit, and the decision does not turn on the figure. RUF00x is firing on the × in
prose like 链数 × timeout, where it is correct.
More to the point: none of the eleven defects the v1.8.0 QA pass found would have been caught by
it. They were type, semantic and concurrency defects — a None reaching a format spec, an
unhashable dict key, an uncaught decode error, a grace-window race, aggregation under the wrong mode.
A gate here would fail on house style while staying silent on the class of defect that actually bites
this project.
tests 305 → 355.
v1.8.0 — QA-loop fixes: two wrong committee readings, three new hard rejections
A QA pass driven by actually running the CLI rather than reading it — seven rounds over the documented
user paths (dry-run, generate, stats, refine, the discussion pipeline, leak-check, config handling),
stopping after two consecutive rounds found nothing above P3, plus what three rounds of independent
pre-ship review turned up. Eleven defects, each reproduced before it was fixed and each locked by a
regression test. Two of them produced wrong committee readings rather than crashes, which is the
failure mode this project exists to prevent.
Three previously-accepted invocations now fail fast. All three used to "work" only in the sense
of exiting 0 while producing something wrong or meaningless; none of them has a legitimate use.
Following the v1.7.0 precedent for ISSUE-008, no opt-out flag ships — a flag would preserve the
silent-failure mode being removed. To go back, pin the previous release.
statswhose--modedisagrees with the artifacts in--collect-dir. The error names the
mode it detected and prints the corrected command. Action: pass the--modeyou generated with.--round 0or negative onrefine/discuss-turn/discuss-prompt(and negative on
stats;stats --round 0stays valid and means the generate round). Action: number rounds
from 1. Forrefinethat is because round 0 is the generate round; for thediscusscommands
it is simply the first speaking turn, and a non-positive round used to be written into
discussion.jsonland shown to later speakers as "第 0 轮".- A member whose
seat:is present but empty and which sets no explicitrole:. An empty
seat resolved to the role key"", whose escaped regex matched an inline##occurrence in
roles-review.md's own prose — so that seat silently ran with the file's documentation preamble
as its role prompt. A member that setsrole:is unaffected ingenerate/refine:role
wins overseatin role resolution, so its seat carries no role meaning there. Thediscuss
commands require a non-empty seat from every member regardless, because thereseatis also the
anonymized speaker identity and theblindvote_<seat>.jsonfilename.
Action: writeA/B/C/D, or drop the key entirely to fall back to the default role.
Fixed
- A finished straggler no longer has its result thrown away at the grace-window boundary.
dispatch_with_quorumharvests completed futures, then registers grace deadlines, then expires
them — so a seat that finished during the harvest callbacks was still inpendingand got recorded
asskipped_graceeven though its result was already in hand. Both directions were wrong: a real
HTTP 401 was laundered into "voluntarily skipped, not a fault", which zeroes out the
members_failed - members_skippeddifferenceSKILL.mdteaches the arbiter to read, and discarded
both the actionable hint and the already-billedusage; a successful straggler lost a paid-for
committee opinion outright — in the reproduction, the only seat votingfailwith a blocker, leaving
statsreporting a unanimouspasswith zero blockers. The expiry check now asksfut.done()first
and takes the real result when there is one. Genuinely-running seats are still skipped exactly as before. statsno longer emits an all-zero tally when--modedisagrees with the artifacts.--mode
defaults toreviewand artifacts do not record their mode, sogenerate --mode brainstormfollowed
by a barestatssilently producedverdict_tally={'?':N},mean_confidence=0.0and zero issues —
andsynthesis.mdrequires the arbiter to copy stats numbers into the report verbatim, so that empty
reading went straight into the deliverable.statsnow infers the mode from the schema keys the
successful seats actually carry and refuses to aggregate under the wrong one. The detector is
deliberately conservative: every successful seat must point at exactly one other mode, and an
ambiguous or unrecognizable shape stays silent. Failed seats are skipped rather than vetoing the
check, so degraded runs — the common case here — are still covered.dry-runno longer crashes on a config written the way the example config's comment tells you
to.dict.get(k, default)only substitutes when the key is absent; YAMLmodel:(explicit
null) returnsNone, which a:<28width format rejects withTypeError. A primary codex
seat is writtenmodel: null— that is whatconfig.example.yaml's own comment instructs
("codex 兜底: model 必须置空") — and the very first step of the documented workflow, showing the
user the roster and cost before spending anything, died on it. (The shipped file itself only uses
model: nullinsidefallbackentries, whichdry_runnever renders, so a stock config was not
affected.) An explicit null in any roster column now renders as that column's empty placeholder
rather than raising or printing the literalNone.- One malformed field no longer destroys the whole refine-round aggregation. ISSUE-002 hardened
compute_statsandcompute_discuss_statsagainst ill-typed member fields but skipped
compute_refine_stats, whereverdictandrevised_claimed_optionare used as dict keys and set
elements. A model writing either as a list or an object raisedTypeErrorat three sites and threw
away every other seat's already-billed output. Non-string values are now excluded, and
early_stop_suggestedadditionally requires every successful seat to have a readable stance —
a stance nobody can read is not agreement. compute_discuss_statstolerates non-string seats.seatdoubles as a dict key and a sort key
there, so mixingseat: 1andseat: Aacross members crashedsorted()on int-vs-str. Seats are
normalized to strings for aggregation; string seats are unaffected.- The
[budget]banner stops promising a fallback that does not exist. It said
"已让位给下一条 fallback" unconditionally, including when the exhausted link was the last (or only)
one on the seat — sending the reader to debug a degradation chain that was never configured. - A straggler whose worker raises is recorded as a failure, not as a voluntary skip. 1.7.1
reached such a seat only through the expiry branch, which overwrote it withskipped_grace— the
same laundering of a fault into "not a fault" as the headline fix above, and the same corruption
of themembers_failed - members_skippeddifference. It is now recorded as a real failure
carrying the exception text. Reachable because the worker runsresolve_channel(member)and
opts["timeout_seconds"]outside_dispatch_channels' ownexcept Exception. - Non-string
seat:androle:values no longer crash role resolution._seat_rolehands the
raw value toload_role_prompt, which feeds it tore.escape— soseat: 1,seat: falseor
role: 123died with a rawTypeErrornaming theremodule, andseat: ["A"]died even
earlier on the unhashableDEFAULT_SEAT_ROLElookup. Both sites now coerce tostr, which is an
identity map for every string seat and role — i.e. for everything the docs, the example config and
decide-mode role injection produce — and lets an odd value fall through to the generic role
sentence that already exists for unknown roles. (One exotic exception: acustom_roleskey that is
not a string, such as YAML's1:oron:, is no longer matched by an equally non-string
role:/seat:; such a pair now lands on the generic sentence.)
Changed
- Named errors replace raw tracebacks when reading a user-supplied path. ISSUE-005 established
this for--input/--inject; the same doors elsewhere still raised library exceptions:- hand-written CH1 artifacts (
member_*.json,blindvote_*.json— the--collect-dirseam where
the arbiter, not the script, writes the file) gaveJSONDecodeErrorwithout naming the file, or
KeyError: 'name'; - a
config.yamlwith misaligned indentation or a tab gave the YAML scanner's error, and a
--configpointing at a directory gaveIsADirectoryError— while the same function already
named the missing-config case; - an unwritable
--collect-dirgavePermissionErrorfrommkdir(the creating commands —
generate,discuss-turn,discuss-blindvote; a write that fails later, e.g.statswriting
into a directory that is read-only but already exists, still raises); - a non-UTF-8 file at any of those four doors gave
UnicodeDecodeError. GBK-saved Chinese briefs and
configs are a routine Windows artifact, andleak_checkhad handled this for its own reads all
along. The message now names the encoding and gives theiconvinvocation.
- hand-written CH1 artifacts (
seatis validated at startup. An emptyseatused to surface either asTypeErrorfrom
re.escape(None)deep inside role resolution — an error with no textual connection to the config —
or, forseat: "", as the silent first-section match described above. Only empty values are
rejected, and only when the member sets norole:;seat: 1and a missingseatkey still work
(see the coercion fix above), because tightening further would be a breaking config change for no
defect.
Known, not fixed here. Two raw-traceback paths remain, both confirmed to behave identically on
v1.7.1 and both deliberately left out to keep the repair round narrow:
options: {}passesvalidate_configbut then raisesKeyError: 'timeout_seconds'at dispatch,
because_dispatch_channelsindexesoptsdirectly while its siblings
(min_successful_members,grace_seconds) use.get()with a default. Write theoptionsblock
asassets/config.example.yamldoes.--modelscombined with a config that parses toNone(a zero-byteconfig.yaml, or
--config /dev/null...
v1.7.1 — regression fixes for v1.7.0
Same-day patch for three regressions v1.7.0's own repairs introduced. A pre-ship reviewer's verdict
landed after the tag went out; these are the items it scoped as in-release, and all three are
defects created by v1.7.0 rather than pre-existing ones. Upgrade from 1.7.0 is recommended for
anyone whose config.yaml has a cli seat, which the first item can stop from starting at all.
Fixed
- A
cliseat whose fallback omitschannel:no longer fails validation. Omittingchannel:
in a fallback entry is legal and meansapi— that is exactly howresolve_channeldispatches
(fb.get("channel", "api")). v1.7.0's new fallback gate judged the merged{**member, **fb}
view, which inherits the member'schannel, so acliseat carrying a plain
{model: …, protocol: openrouter}fallback was rejected at startup with an error that called
that linkchannel=cli. The gate now pinschannelto the fallback's own value and judges the
remaining keys (cli_kind/model/auggie_model) on the merged view, which is what
_expand_cliactually reads. Genuinely ambiguous fallbacks — an explicitchannel: cliwith no
cli_kindand noauggie_model— are still rejected. --retry-timeoutis integral again. v1.7.0 started handing the cli repair round the
remaining link budget as a float, and the flag is built withstr(max(30, timeout // 3)), so
auggie received73.0where it had always received73. If its parser is strict that turns the
one-shot JSON self-repair into a guaranteed non-zero exit on every auggie seat — three of the four
in the shipped committee. Nowint()-wrapped at the point the argument is built, so it holds
whatever the caller passes.- The fast exit now stands down while a CLI call is in flight. v1.7.0 reasoned that
os._exit
only skippedTemporaryDirectory's cleanup and deleted the registered directories to compensate.
It also killssubprocess.run's own timeout watchdog: an abandonedauggie/codexchild is
reparented and runs unbounded (this project has measured auggie's internal retry at over seven
minutes, billed at upstream +40%), and deleting the directory pulled its workspace out from under
it. When any CLI call is still running the process now takes the regular shutdown instead — the
pre-1.7.0 behavior, so nothing regresses; it simply forgoes the speed-up in that one case, and the
watchdog, the context manager and normal child reaping all work again. The directory deletion
described in 1.7.0's ISSUE-009 entry is gone with it.
tests 228 → 231.
v1.7.0 — degradation resilience
Degradation-resilience pass over the three-channel fallback path, driven by three instrumented
probes (kept in the gitignored tasks/probes/, re-runnable, no real requests). The probes showed
that channel switching was immediate but failure detection was effectively unbounded, and that
one of the two channel branches did not degrade at all.
Upgrading from 1.6.x — read this. Two defaults change, and both can alter what a run does.
timeout_secondsnow means "per fallback link", not "per HTTP attempt". A seat that used
to succeed only by burning two or three retries past its timeout can now be cut at the budget
boundary and handed to the next fallback instead. Action: if a seat legitimately needs
retry headroom, raise that seat'stimeout_seconds— a seat's worst case is now
expanded links × timeout_seconds, so the value is readable as a bound. Count expanded
tries: acliseat with no explicitcli_kindbecomes two links (auggie, then codex) when
both binaries are present. Everycliseat in the shipped config setscli_kind, so there the
two counts agree. Nothing to change if
your seats normally answer on the first call. The first time a link is actually cut, the run
prints a one-time[budget]note on stderr explaining this and naming the seat, so you do not
have to have read this entry.- A
cliseat withmodel:but nocli_kind:and noauggie_model:is now rejected at
startup instead of silently running auggie's default model. Action: the error names both
fixes — addauggie_model: <auggie-side id>, or setcli_kind: codex/auggieexplicitly.
Everycliseat in the shippedconfig.example.yamlalready setscli_kind, so a stock
config is unaffected.Revert path: no runtime opt-out flag ships for either change — both are corrections to what
the existing knob and the existing config already claimed to mean, and a flag would preserve the
silent-failure mode they remove. To go back, pin the previous release:
/plugin install moa@moa-skillafter/plugin marketplace add sdsrss/moa-skill#v1.6.2, or for a
direct-copy installgit checkout v1.6.2 -- skills/moa.
Changed
timeout_secondsnow bounds a whole fallback link, not a single HTTP attempt. It was applied
per attempt and handed unchanged to every link, socall_model's two internal retries multiplied
it: a probe with threeapilinks attimeout_seconds: 240issued 9 HTTP attempts and could
run 2169 s (~36 min) for one seat, and the quorum grace window did not help because it only
opens after quorum is reached — when two of three seats fail, it never opens at all, so the
bound was missing exactly when degradation mattered. Each link now gets its own wall-clock budget
covering its retries, backoff and JSON repair round; a link that exhausts it fails with
err_class: budgetand yields to the next one. A seat's worst case is thereforeexpanded links × timeout_seconds— expanded, because acliseat with no explicitcli_kindbecomes two tries
(auggie, then codex) when both binaries are present; the shipped config setscli_kindon every
cliseat, so there the config count and the expanded count agree. The budget is allocated
per link rather than shared across the seat, so
every fallback the user configured is still tried at least once. What is tightened is wall clock,
not retry policy: fast failures (a 429 returned immediately) consume almost no budget and still
get the full retry count, and the common "one call, succeeds" path is unchanged. No new config
option — this makes the existing knob mean what it reads like. (ISSUE-007)- A
cliseat that would run an unknown model is now rejected at config validation instead of
warned about.channel: cliwithout an explicitcli_kindresolves to auggie when the binary
is present, and that path only honoursauggie_model— so a member carryingmodel:but no
auggie_model:silently ran auggie's default model and recordedmodel_used: null. The cost is
not merely "config ignored":references/synthesis.mdrequires the arbiter to disclose each
seat's model family so readers can discount an "everyone agrees" result, and a seat of unknown
family makes that hard rule unexecutable — cross-family de-correlation is the committee's whole
premise, so silently running an unknown model is a correctness failure, not an acceptable
degradation. The error names both fixes (addauggie_model, or setcli_kindexplicitly). Every
cliseat in the shippedconfig.example.yamlsetscli_kind, so this gate cannot fire on a
stock config; a test now asserts that. (ISSUE-008)
Fixed
-
generate/refineno longer hang after they are done. Stragglers abandoned by the grace
window keep running in background threads, andconcurrent.futures' atexit handler joins them
at interpreter shutdown — a probe measured the dispatcher returning at 0.55 s while the process
only exited at 6.1 s, which at the defaulttimeout_seconds: 240means up to 4 minutes of
silence afterdoneis printed. That lands precisely whereSKILL.mdstep 3 tells the arbiter to
backgroundgenerateand dispatch CH1 seats in parallel, and it stallsgenerate && stats
chains.main()now exits the process directly once a straggler has been abandoned. Artifacts are
safe: everywrite_memberhappens on the main thread'son_donecallback, so abandoned threads
never write files. Before exiting it also deletes the CLI channels' temp directories, which the
abandoned thread is parked inside and whichos._exitwould otherwise leave behind — the auggie
channel writes the whole briefing toprompt.txtin there, so skipping the cleanup would strand
review material in the system temp dir. Only the clean path does this — a non-zerosys.exit
still takes the regular (slower) shutdown, where the user is reading an error anyway. (ISSUE-009) -
apiseats now degrade on unparseable output, likecliseats already did. The two channel
branches of_dispatch_channelshandled the same failure asymmetrically: when a member's output
could not be parsed as JSON even after the repair round, theclibranch raised and the seat fell
through to the next link in itsfallbackchain, while theapibranch returned aparsed=None
result immediately — consuming the seat and voiding every remaining fallback link, so anapi
seat with a configured degradation chain behaved as if it had none. The failure also recorded
err_class: null, making this class invisible to the error tally instats.json, and dropped the
usageof the two calls (generate + repair) that had already been billed, so cost reporting
under-counted exactly when things were going wrong. Theapibranch now records aparse-class
failure carryingusageandraw, then continues down the chain._failtakes optional
usage/rawarguments; all other call sites keep their previous shape. (ISSUE-006)Member artifacts for failed seats now always carry a
usagekey (nullwhen nothing was billed).
This holds even when the repair round itself raises — the generate round's already-billedusage
and itsrawride out on the exception rather than dying with the stack frame, which matters
because the per-link budget below makes "generate ate the budget, repair cannot run" a routine
path rather than a rare one. -
A failed seat's artifact now names the link that actually ran.
_failbuiltmodel_usedand
protocolfrom the member's primary channel while therawandusageit carried came from
whichever fallback link produced them, so one link's bytes shipped under another link's identity —
androster[].model_knownwould report the config'smodelas known for a seat that never ran
it, defeating the family-composition checkreferences/synthesis.mdasks the arbiter to perform.
Failure records inside the fallback loop now carry the real link'smodel_used/protocol/
channel_used; the last of those also restores a field v1.6.2 populated and the first cut of this
release had regressed tonull.
Added
members_skippedseparates abandonment from failure. A seat dropped when the grace window
expired is a deliberate "we are not waiting for it", not a fault, but it counts in
members_failedall the same.members_failedkeeps its old meaning (every non-successful seat)
so existing readings do not shift;members_skippedis a subset of it, and real failures are
members_failed - members_skippedwithout readingerr_classseat by seat. (ISSUE-011)roster[].model_knownflags seats running a channel default model —cli_kind: codexwith
model: null, the shipped fallback idiom, is one — whose family cannot be determined.
synthesis.mdnow tells the arbiter to exclude those seats from family counts and name them in
the report rather than guessing from the config. (ISSUE-008 companion)dry-runstates that its call count is a lower bound, naming what it excludes: JSON repair
rounds, transient retries (with theirmax_tokensdoubling), discussion rounds, and subscription
seats that turn billable on degradation.
Deliberately not shipped
- Aggregate reporting of what failed seats burned. This release grew
token_usage.wasted_tokens
/wasted_membersand then withdrew them before release, because pre-ship review showed the pair
wrong in both directions at once. Under-count: the largest single token sink in the file is
call_model's truncation retry, which doublesmax_tokenseach attempt — measured at
3000 → 6000 → 12000, i.e. 21000 tokens genuinely billed across three HTTP 200s — and it discards
each attempt'susageinside its own retry loop, so the figure never reaches the aggregator and
reports0....
v1.6.2 — QA-loop robustness hardening
Robustness hardening from an autonomous QA self-test loop (5 rounds). All changes are bugfixes / fail-fast on already-broken input; no behavior change for valid configs or well-formed member output.
Fixed
- ISSUE-001 — non-object member JSON (a top-level array/scalar) no longer crashes
stats;parse_jsonreturns dict-or-Noneand the aggregation layer gates success onisinstance(parsed, dict). - ISSUE-002 — nested member fields (
issues/ideas/confidence/…) are type-guarded; a single malformed seat no longer crashes the whole aggregation. - ISSUE-003 —
min_successful_members/timeout_seconds/max_tokens_memberare validated likegrace_seconds; quoted values give a named[config]error instead of a traceback. - ISSUE-004 —
discussnow requires unique seats (seat is the anonymized speaker identity); duplicate seats stay legal ingenerate/refine. - ISSUE-005 — missing
--input/--injectfiles give a named error instead ofFileNotFoundError.
Testing
Suite 177 → 209 (+32). leak-check clean; version synced across 6 manifests; CI green (3.9 / 3.12 / version-check).
See CHANGELOG.md for full detail.
v1.5.0 — four-family default + CI + audit P3 fixes
See CHANGELOG.md for the full entry.
Migration note (user-visible default change): the default committee now fields a genuine fourth family — seat D moved from a free second-Anthropic Self-MoA subagent to Moonshot kimi-k2.7 via auggie. This fixes a correlation the v1.4.0 audit flagged (seats B, D and your Claude arbiter were all Anthropic — 3 of 5 judging minds one family). Cost impact: the default now bills 3 seats (A/C/D) instead of 2. To revert, swap seat D back to an Opus subagent (block commented in config.example.yaml); on machines without auggie the seat auto-falls-back to codex.
Highlights
- New GitHub Actions CI: tests + leak-check across Python 3.9/3.12; tag-time version/badge consistency (audit F1).
- Five P3 correctness fixes (F2–F6), each with regression tests; suite 162 → 169.
- Disclosures: cross-seat prompt-injection propagation, arbiter-same-family correlation, confidence-as-ordinal; README platform range.
v1.4.0 — auggie CLI channel (auto-preferred) + reasoning-truncation fix
Migration note: bare
channel: clinow meanscli_kind: auto— when theauggiebinary is on PATH it is preferred over codex (one-time stderr banner). In auto mode the auggie try does NOT inheritcli_extraand takes its model only fromauggie_model. Opt-out / revert: setcli_kind: codexon the member for exact pre-1.4.0 behavior. auggie is a billed channel (upstream API price +40% via your Augment plan) —dry-runcounts it asbilled.
Added
- CH2 second CLI kind: auggie (
cli_kind: auggie|codex|auto, default auto → auggie preferred when detected). One auggie account serves all committee families (GPT/Gemini/Claude/Kimi/GLM). Hardening: prompt via--instruction-file(not argv), empty--workspace-rootsandbox (no codebase-context injection into blind review),--output-format jsonenvelope,--max-turns 1,--dont-save-session,--retry-timeout(measured: concurrent Augment 503 retries hung >7 min without it). - CLI-path JSON repair round: cli seats (codex and auggie) get one self-repair call on unparseable output before falling to the next channel, matching the api path.
channel_usedrecords the concrete kind (cli:auggie/cli:codex).- Default committee: seats A (gpt5.6-sol) and C (gemini-3.1-pro-preview) move to auggie with codex/api fallbacks; 4th-family alternative switches from grok (separate x-ai key, mostly list-only 404) to auggie
kimi-k2.7.
Fixed
- Reasoning-model truncation on the api path (the OpenRouter gemini empty-shell bug): reasoning models can burn the whole
max_tokensbudget on reasoning (finish_reason=length, empty content); the old retry re-sent the identical request.call_modelnow doubles the budget per retry (cap 16000) and returns partial content best-effort on the final attempt.
Tests
- 137 → 162; real E2E: two auggie seats (explicit + auto-detected) generate 2/2 → stats consumed normally.
v1.3.3 — audit fixes: quorum denominator + hardening
Fixes from a full second-pass audit of v1.3.2 (report: docs/audit-report-2026-07-12-v1.3.2.md).
Fixed
- Quorum denominator (N1, P1):
generate/refinescoped themin_successful_membersgate to all seats (including pure CH1 subagent seats) while only counting dispatchable (CH2/CH3) successes — so the default 2-subagent + 2-dispatchable committee would false-[abort]"not enough advisors" when a single dispatchable seat failed, defeating the fallback/quorum degrade-and-continue path. The gate is now scoped to dispatchable seats; an all-CH1 config exits cleanly instead of aborting. load_transcriptskips corruptdiscussion.jsonllines (stderr count) instead of crashing the whole discussion.blindvote_<seat>.jsonfilenames pass through_safe_name(path-traversal gap onseat).
Added (tests, 126 → 137)
leak-checkexit-1 (secret-found) regression test — previously only exit-0/exit-2 were covered._dispatch_channelsfallback-chain traversal,http_posttransport build, andmain()argparse wiring smoke tests.
Docs
- Disclosed the line-level secret-scan false negative in SKILL.md inherent-limitations.
discuss.md--member <member_name>(filter matches name, not seat);roles-decide.mdis_factwording aligned to the generation schema;config.example.yamlname-vs-role clarification +codex_bin; quorum phrasing corrected across SKILL.md + both READMEs; English Troubleshooting table for parity; new rootCHANGELOG.md.
Full offline suite: 137 passed. leak-check clean. Version consistent across all six locations.