amicus 4.7.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- package/src/workspace/seat-space.js +143 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,954 @@
|
|
|
3
3
|
All notable changes to Amicus are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
-
## [
|
|
6
|
+
## [4.8.0] - 2026-08-23
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **A council member literally named `toString`, `constructor`, `valueOf` or `hasOwnProperty` is
|
|
11
|
+
no longer accepted as a valid model alias.** The effective alias table
|
|
12
|
+
(`config.js :: getEffectiveAliases`) was a plain object, so it inherited `Object.prototype`, and
|
|
13
|
+
every gate that asks *"is this a known alias?"* by indexing the table read those inherited methods
|
|
14
|
+
as a **truthy** answer. Five gates were affected: `resolveModel`, `classifyCouncilMembers`
|
|
15
|
+
(`--council` presets), `amicus council save`, pack validation, and launch-time route resolution.
|
|
16
|
+
The sharpest consequence, measured: `resolveModel('toString')` returned **the function itself**
|
|
17
|
+
— `typeof 'function'` — where every caller expects a model-id string, instead of throwing
|
|
18
|
+
*"Unknown model alias"* the way any other unknown name does. The table is now created with
|
|
19
|
+
`__proto__: null` — the same protection this release already gave its other lookup tables. A member
|
|
20
|
+
with one of those names is now dropped exactly like any other unresolvable alias, with the same
|
|
21
|
+
message, and `resolveModel` throws *"Unknown model alias"* instead of returning a function.
|
|
22
|
+
⚠️ **One table was not enough.** The curated builders behind `DEFAULT_ALIASES`
|
|
23
|
+
(`curated-models.js :: toDefaultAliases`, `:: toGatewayRoutes`), the copy handed out by
|
|
24
|
+
`getDefaultAliases`, and the alias map the interactive `amicus setup` composes from free-form
|
|
25
|
+
input all needed the same seed — **a spread into a plain `{}` re-creates the inherited
|
|
26
|
+
prototype**, so fixing one map does not fix a map built from it. All three are now seeded.
|
|
27
|
+
One surface is deliberately out of scope and filed: the Electron
|
|
28
|
+
setup wizard re-embeds the alias table into its page through `JSON.stringify`, and the parser
|
|
29
|
+
always re-materialises `Object.prototype` on the far side. That path is behaviourally inert —
|
|
30
|
+
`JSON.stringify` drops function values before the write, and `saveConfig` rejects `__proto__`. On the
|
|
31
|
+
null-alias auto-repair path specifically, an alias named `toString` used to "repair" to
|
|
32
|
+
`function toString() { [native code] }` and say so on stderr; it now reports the misconfiguration
|
|
33
|
+
it actually is.
|
|
34
|
+
⚠️ **Disclosed rather than buried: the preset-member trim in this same release briefly made
|
|
35
|
+
this worse, and that is why it is fixed here.** Before the trim, `"toString "` (with the trailing
|
|
36
|
+
space) missed the inherited property and was correctly dropped; trimming before the lookup landed
|
|
37
|
+
the padded spelling on it too. Fixing only the half this release introduced would have taken more
|
|
38
|
+
code *and* knowingly left the other half open.
|
|
39
|
+
|
|
40
|
+
- **A finding whose `raiser` is empty or missing no longer counts its own vote as peer
|
|
41
|
+
corroboration.** `""` is not a model id, but the council-tally input schema accepts it, and the
|
|
42
|
+
tally used to treat an unknown raiser as "exclude nothing" — so a document with `raiser: ""` and
|
|
43
|
+
a `judge: ""` adjudication scored that adjudication into `findings[].basis`. Measured: a finding
|
|
44
|
+
with votes `["" agree, "gpt" agree]` read `{a:2}` **Confirmed (solid)**, where the same shape
|
|
45
|
+
with a named raiser reads `{a:1}` (thin).
|
|
46
|
+
The rule is now one principle — *attribute when you can, mark only when you cannot* — applied in
|
|
47
|
+
order. **Seat ids decide first, and no longer need a known raiser:** when the adjudication *and*
|
|
48
|
+
the finding both carry a seat id, the same seat means the raiser's own vote (excluded) and
|
|
49
|
+
different seats mean a real peer (counted), whatever `raiser` and `judge` say. Only when the seats
|
|
50
|
+
cannot decide does the name matter: a named raiser excludes by alias exactly as before, and an
|
|
51
|
+
**unnamed** one keeps every **named** judge — so no real peer is dropped for want of a raiser —
|
|
52
|
+
while dropping the votes whose judge is equally unidentifiable. Both fallback arms' unattributable
|
|
53
|
+
drops — and only those — are **announced** in `findings[].unattributedPeerDrops`: a vote the seats
|
|
54
|
+
attributed is not ambiguous, so it is never counted there. The key's presence therefore means "a
|
|
55
|
+
vote could not be placed", not "the raiser was unnamed".
|
|
56
|
+
Consequences on such a document: `basis` can move and the tier with it — a finding whose only
|
|
57
|
+
votes are unidentifiable now reads `Singleton` rather than `Confirmed` or `Disputed`. On a
|
|
58
|
+
document that **names** its raisers, *this* fix changes nothing: the peer split there is
|
|
59
|
+
byte-identical to the pre-fix behaviour, measured over 300,000 randomized named-raiser findings
|
|
60
|
+
with zero differences.
|
|
61
|
+
⚠️ **That is a claim about this fix, not about v4.7.1.** The `unattributedPeerDrops` mark below
|
|
62
|
+
landed earlier in the same release and it fires on named-raiser documents too — where the finding
|
|
63
|
+
and the vote carry a seat id on only **one** side — so a document that names its raisers can still
|
|
64
|
+
carry a key that a v4.7.1 one did not. Anything claiming such documents are unchanged *by the
|
|
65
|
+
release* is false; they are unchanged by this fix. On an ordinary bench, where no alias repeats and
|
|
66
|
+
`raiserSeat` is absent by design, that shape does not arise.
|
|
67
|
+
The same predicate builds the defense brief,
|
|
68
|
+
so the brief moved with the tally; a finding that falls off `Contested`/`Disputed` no longer
|
|
69
|
+
reaches a brief at all.
|
|
70
|
+
⚠️ **This does not close the twin-seat case.** Where a *named* raiser's finding and an
|
|
71
|
+
adjudication carry seat ids on only **one** side, the vote is still excluded and still only
|
|
72
|
+
announced — deliberately, and unchanged by this release.
|
|
73
|
+
⚠️ What those two track is a **possible** undercount, not an established one. Such a vote is
|
|
74
|
+
either a real twin's signal being discarded or the raiser's own being correctly excluded, and
|
|
75
|
+
nothing in the document distinguishes them — which is exactly why the drop is announced rather
|
|
76
|
+
than silently taken. Read the count as "up to N votes of peer signal may be missing here", never
|
|
77
|
+
as "N are".
|
|
78
|
+
|
|
79
|
+
- **The Workspace's dead-seat rows no longer collapse, and a live seat no longer erases its dead
|
|
80
|
+
twin, on a bench that repeats an alias.** Two measured defects. Two dead twins rendered as a
|
|
81
|
+
single row (`deadSeats` dedup'd on the alias); and with one twin alive and the other genuinely
|
|
82
|
+
dead, the dead seat produced **no output anywhere in the panel** — silent data loss, which this
|
|
83
|
+
project's product principle rates as severely as a crash. Dead-seat rows, their retry badges and
|
|
84
|
+
their DOM keys are now keyed on the seat. The retry badge also lands on the seat that was
|
|
85
|
+
actually retried instead of on its live twin.
|
|
86
|
+
The producer carries the identity that makes this possible: still-dead notes now record a seat
|
|
87
|
+
id on every arm this surface reads, and — deliberately — emit `null` rather than the alias for a
|
|
88
|
+
seat the run could not identify, because "unidentified" and "the alias" are different statements and
|
|
89
|
+
collapsing them is what merged two dead seats into one row. Unique-alias benches are unchanged:
|
|
90
|
+
a seat id there equals its alias byte-for-byte.
|
|
91
|
+
⚠️ **Disclosed residuals.** Where a degrade record does not name *which* seat died, no consumer
|
|
92
|
+
can attribute it, so on a bench that repeats an alias: two such records still collapse to one
|
|
93
|
+
row; a dead seat beside a live twin is still hidden; and a retry badge still marks every seat
|
|
94
|
+
sharing the alias. This is **not** limited to runs recorded before this release — a seat the
|
|
95
|
+
producer could not identify yields the same alias-valued key on a new run. Two further cases are
|
|
96
|
+
filed and unfixed: the **critic** path is still alias-keyed, and during a **live run** a stale
|
|
97
|
+
record naming a seat that is alive can show a dead row for it until the terminal refresh. Every
|
|
98
|
+
one of these is pinned by a test asserting the known-wrong behaviour so it cannot rot silently.
|
|
99
|
+
A seat the producer emitted `null` for — a **dead wave** it could not identify — is not among
|
|
100
|
+
them: such a row is never hidden by a live seat sharing its alias, because "unidentified" and
|
|
101
|
+
"the alias" are different statements and a degrade record means that seat stayed dead after its
|
|
102
|
+
retry. An unidentifiable **dead leg** is a different case and *is* among them: its key falls back
|
|
103
|
+
to the alias, so it collapses with another such record and is suppressed by a live twin exactly
|
|
104
|
+
as a pre-release record is.
|
|
105
|
+
⚠️ The trade this makes, stated plainly: still-dead notes are deduplicated only where seat
|
|
106
|
+
identity is **exact** — the leg was bound to a seat, or its alias holds exactly one seat, where
|
|
107
|
+
the alias *is* the id. Where identity is unknown the seat is announced rather than assumed to be
|
|
108
|
+
a repeat, so on a bench that repeats an alias **one dead seat can be announced twice**. Two
|
|
109
|
+
earlier designs inferred the answer instead (a per-alias budget, then a roster pigeonhole) and
|
|
110
|
+
both hid a real dead seat when the inference was wrong. A duplicate a reader can see is
|
|
111
|
+
preferred to a loss they cannot.
|
|
112
|
+
- **`streetCred[]` no longer drops or invents a row when a document's `meta.seats` table disagrees
|
|
113
|
+
with `meta.models` in count, and a reliability-ledger pair group with partial seat information no
|
|
114
|
+
longer reads narrower than one with none.** The **row-count** defect is unreachable on the engine
|
|
115
|
+
path — `seats.js :: buildSeats` derives `meta.seats` from the same bench array that becomes
|
|
116
|
+
`meta.models`, so the two agree by construction — and reachable only on the two hand-assembled
|
|
117
|
+
`appendRun` paths: the `amicus_council_tally` MCP tool, whose schema declares `meta.seats`
|
|
118
|
+
independently of `meta.models`, and `cli-handlers-council.js`'s `runTally`, which parses
|
|
119
|
+
user-supplied JSON with no schema at all. The **mixed-group** defect is narrower but *is*
|
|
120
|
+
engine-reachable: a chair-synthesis row carries no seat and still joins the ledger, so on a twin
|
|
121
|
+
bench where one twin also chairs and the twins resolve to different executables, that pair group
|
|
122
|
+
is mixed on an ordinary run. All of these write into `council-ledger.jsonl`, a file that is never
|
|
123
|
+
migrated.
|
|
124
|
+
`street-cred.js :: credSeats` used to expand the *first* `models` occurrence of an alias the seat
|
|
125
|
+
table named into every id registered for it at once — inventing a row when the table over-registers
|
|
126
|
+
a non-repeated alias — while skipping every *later* occurrence of that alias outright — dropping a
|
|
127
|
+
row when the table under-registers a repeated one. The k-th occurrence of alias `m` now takes the
|
|
128
|
+
k-th id the table registered for `m`; once that list is exhausted (or the table never named `m` at
|
|
129
|
+
all), the occurrence gets an alias-keyed row (`seat: null`) instead of vanishing, and a surplus
|
|
130
|
+
registered id simply goes unused instead of manufacturing an extra row.
|
|
131
|
+
`streetCred.length === meta.models.length` now holds on every document the tally produces,
|
|
132
|
+
however far the seat table disagrees with the model list.
|
|
133
|
+
`ledger-join.js :: credFor` used to resolve a pair group's street cred through the seat table the
|
|
134
|
+
moment *any* row in the group resolved that way, discarding the numbers of any other row in the
|
|
135
|
+
*same* group whose seat did not resolve — so a MIXED group (one seated row, one not) read narrower
|
|
136
|
+
than a group with NO seat information at all, which fell straight to the honest alias-mean
|
|
137
|
+
fallback. The seat lookup now wins only when *every* row in the group resolves
|
|
138
|
+
(`ids.length > 0 && ids.every(id => id && sc.has(id))`); anything short of that — one row unseated,
|
|
139
|
+
one seated to an id the table doesn't hold, or an empty group — falls through to the same
|
|
140
|
+
alias-mean fallback a fully-unseated group already used. A mixed group now reads identically to a
|
|
141
|
+
fully-unseated one.
|
|
142
|
+
⚠️ Fixing the row-count side of this also moves `streetCred[]` row *order* on an ordinary engine
|
|
143
|
+
bench that repeats a non-adjacent alias — a real, disclosed, accepted behaviour change, not a
|
|
144
|
+
malformed-input-only one. See the street-cred entry under **Changed**, below, for the measurement.
|
|
145
|
+
|
|
146
|
+
- **A debate re-vote the engine cannot attribute to a bench seat is now refused and announced,
|
|
147
|
+
instead of silently inventing a voter.** On a bench that repeats an alias, a re-vote leg that
|
|
148
|
+
matched no seat on its wave's roster fell back to keying on the bare alias — which matches none of
|
|
149
|
+
the seat-attributed adjudications already on record — so the round **appended a brand-new
|
|
150
|
+
adjudication row** rather than replacing one. Two things went wrong at once: the judge's paid
|
|
151
|
+
re-vote was discarded and its stale provisional verdict stood, *and* a phantom voter corresponding
|
|
152
|
+
to no bench position was counted beside it. Measured end to end on a twin bench, on a finding with
|
|
153
|
+
two eligible judges: **three votes**, basis `{a:2, d:1}`, tier **Confirmed**. Those counts feed
|
|
154
|
+
the tally and the verdict, so this was a correctness defect in the basis, not a rendering blemish.
|
|
155
|
+
Now such a leg's votes are **withheld** and the refusal is announced on the **`seat-unbound`**
|
|
156
|
+
channel — the same channel a Stage-1 leg that matches no seat already uses. The same fixture
|
|
157
|
+
now yields **two** adjudications, no seat-less row, basis `{a:1, d:1}`, tier **Contested**, and one
|
|
158
|
+
degrade note naming the leg and the wave.
|
|
159
|
+
⚠️ **A refusal degrades the run, so an otherwise-clean run exits 2** — exactly as an
|
|
160
|
+
unattributable Stage-1 leg already made it. That is the announcement working, not a new failure
|
|
161
|
+
mode: nothing crashes and every artifact is still written.
|
|
162
|
+
**The leg itself is untouched.** It ran and it cost money, so it still contributes its `runStats`
|
|
163
|
+
row, its `revote-<model>.md` and its `conformance`; only the parsed votes are withheld.
|
|
164
|
+
`debate.json` gains no "refused" row, so on such a run `revoteJudges` and `revoteApplied` will
|
|
165
|
+
visibly disagree — the degrade note is what explains the difference.
|
|
166
|
+
**The refusal is surgical, not a blanket revert.** On the same wave a twin whose leg *did* bind
|
|
167
|
+
still has its re-vote applied. A bench with no repeated alias is unaffected in every case: a seat
|
|
168
|
+
id there *is* its alias, so the key still joins and the vote still lands, byte-for-byte as before.
|
|
169
|
+
⚠️ **The refusal first shipped one case short, and that case is now closed too.** The original
|
|
170
|
+
guard also published a leg that had *bound* to a roster slot, on the reasoning that a bound leg is
|
|
171
|
+
an accounted-for leg. It is not: a leg is matched to a slot by its task id alone, with no check
|
|
172
|
+
that its model name is one the wave asked for. A re-vote leg carrying a **foreign** model name
|
|
173
|
+
could therefore land in a slot, be published under that foreign name, and invent exactly the
|
|
174
|
+
phantom voter this entry is about — the defect surviving through the guard meant to close it. On
|
|
175
|
+
the fixture that reproduces it: the finding went from two votes to **three**, the extra one
|
|
176
|
+
attributed to a model that never sat on the bench, while the seat it displaced kept its stale
|
|
177
|
+
verdict. That arm is now **deleted**: a re-vote is published only when its key names a judge the
|
|
178
|
+
wave actually launched. The one shape the arm existed to protect — a judge left unseated by an
|
|
179
|
+
earlier stage — is unaffected, because such a judge is still one the wave launched.
|
|
180
|
+
⚠️ **The announcement's wording changed with it.** It used to say the leg "matches no seat on
|
|
181
|
+
that wave's roster" and explain the refusal as "it bound to no roster slot, and its judge alias
|
|
182
|
+
'…' names no seat there either". Both are false for exactly the case just described, since that
|
|
183
|
+
leg *did* match a slot. `what` and `why` now state the one condition — the leg's join key names
|
|
184
|
+
none of the judges that wave launched — and `effect` no longer presumes a judge the refusal
|
|
185
|
+
denies: it reads *"the re-vote was NOT applied; the provisional verdict stands"*. The channel and
|
|
186
|
+
the machine-readable field names are unchanged; a leg reporting no model name at all now reads
|
|
187
|
+
`unknown` in `judge` and `key` rather than dropping the key from the record's JSON.
|
|
188
|
+
⚠️ **Not reachable from the production launcher today** — the wave launches only the aliases its
|
|
189
|
+
own judge list names, so every real leg comes back keyed to a judge that wave launched. This is a
|
|
190
|
+
latent-correctness fix rather than a live regression, reachable through a resumed or
|
|
191
|
+
hand-assembled run.
|
|
192
|
+
The companion change is documentation only: `applyDebate`'s docblock now states what an omitted
|
|
193
|
+
`aliasOf` projection does — it leaves the raw seat key in the alias-space `judge` field. No caller
|
|
194
|
+
in this package omits it, the package's `exports` map blocks a deep require from outside, and the
|
|
195
|
+
parameter was deliberately **not** made required.
|
|
196
|
+
- **Four object-literal lookup tables on the council document path no longer resolve
|
|
197
|
+
`Object.prototype`'s own keys.** A verdict, action, or vote string of `toString`, `__proto__`,
|
|
198
|
+
`constructor`, or `valueOf` used to resolve an inherited function instead of `undefined` when read
|
|
199
|
+
as a lookup key — silently corrupting or discarding data instead of falling through the guard
|
|
200
|
+
meant to catch an unrecognized value. Fixed at the table itself (`__proto__: null`, or
|
|
201
|
+
`Object.create(null)` for the one write-site accumulator), not at each consumer, so no future
|
|
202
|
+
caller can reintroduce the hole by forgetting a guard.
|
|
203
|
+
Three of the four are reachable today. `tally.js`'s `VERDICTS`: an unknown verdict used to serialize
|
|
204
|
+
`basis["function toString() { [native code] }"] = NaN` as `null` in both `tally.json` and
|
|
205
|
+
`verdict.json` — reachable on the CLI path only, since the MCP schema's `adjudications[].verdict`
|
|
206
|
+
is `z.enum(['agree','dispute','neutral'])` and rejects the value before `tally()` ever runs.
|
|
207
|
+
`street-cred.js`'s `perJudgeRank`: a judge or seat id of `__proto__` silently lost its rank to the
|
|
208
|
+
inherited setter instead of being recorded as an own key — reachable on **both** the CLI and MCP
|
|
209
|
+
paths, since neither `judge` nor `seat` there carries any value constraint. `report.js`'s `SYMBOL` (the vote
|
|
210
|
+
glyph read by all three matrix renderers) is reachable on **both** paths too — the CLI path
|
|
211
|
+
(`council report`/`council verdict --render`, raw `JSON.parse`) and a second, independent MCP
|
|
212
|
+
tool, `amicus_verdict`, whose `record: z.record(z.any())` input is wholly unvalidated because that
|
|
213
|
+
tool never calls `tally()` and so never meets the `z.enum` guarding `VERDICTS`. With `render: true`,
|
|
214
|
+
`amicus_verdict` feeds that record straight into `buildReport()`, so an adjudication `verdict` of
|
|
215
|
+
`toString` used to render the literal `function toString() { [native code] }` into `report.html`.
|
|
216
|
+
Only one of the four closes a latent hole rather than a live one. `debate.js`'s `PAST_TENSE`
|
|
217
|
+
guards an `action` key that a separate, pre-existing allowlist (`parse-stage2.js`'s
|
|
218
|
+
`parseDebateDefense`) already normalizes to the literal `'no-response'` before this table is ever
|
|
219
|
+
consulted by a real defense response — the two guards are independent and each is individually
|
|
220
|
+
sufficient, confirmed by two tests that go red only when both guards are removed at once. This
|
|
221
|
+
closes a hole behind an allowlist that already covers it, not a bug a real run could hit today.
|
|
222
|
+
⚠️ **Not a rendering fix.** `report.js`'s three renderers already disagreed on how they display any
|
|
223
|
+
unrecognized verdict, `Object.prototype` key or not — `report-md.js` and `report-html.js` both
|
|
224
|
+
print the literal string `undefined`, and the Workspace's `matrix-model.js` prints `?`. That
|
|
225
|
+
disagreement pre-dates this change and is unchanged by it; reconciling the three is a
|
|
226
|
+
rendering-contract decision, filed separately.
|
|
227
|
+
- **The second-opinion skill's own documentation defined the wrong tier for a lone corroborating
|
|
228
|
+
peer, which could make Claude mis-present a finding while running the skill.** `tally.js ::
|
|
229
|
+
assignTier` returns **Confirmed** (`confidence: thin`) for a finding with one peer agreement and
|
|
230
|
+
no disputes (`a=1, d=0`) — verified by execution across ten boundary `(a,d)` pairs, not read off
|
|
231
|
+
the source. `skills/second-opinion/SKILL.md` stated the opposite in two places: its formal
|
|
232
|
+
Singleton definition (`d = 0` and `a < 2`) and a separate prose paraphrase ("at most one
|
|
233
|
+
endorsement, no pushback") both classified that same cell as Singleton instead of Confirmed.
|
|
234
|
+
`skills/second-opinion/COUNCIL-DESIGN.md`'s cascade table carried the same error in its Singleton
|
|
235
|
+
row and, found while fixing it, its Confirmed row was independently incomplete — it never listed
|
|
236
|
+
the `a=1, d=0` case at all, so the Singleton row's `else` swept that cell up, which is exactly how
|
|
237
|
+
it landed in the wrong tier. Both files now put the lone corroborating peer in Confirmed and
|
|
238
|
+
reserve Singleton for `a = 0` and `d = 0`, matching `assignTier` and `docs/council.md`'s cascade
|
|
239
|
+
table, which was already correct and is unchanged. ⚠️ **The formal definitions are fixed; the
|
|
240
|
+
Stage-4 presentation headings are not.** Both files still gloss the consensus tier as "(≥ 2 peer
|
|
241
|
+
agreements, agrees dominate)" at the heading that drives how Claude presents findings, which omits
|
|
242
|
+
the lone-peer case — filed separately, not closed here.
|
|
243
|
+
- **A headless leg with no output, reasoning, or tool calls could be killed by the no-output
|
|
244
|
+
backstop before a normal-speed model finished its first turn.** The default window was 120
|
|
245
|
+
seconds; CI carried a `300000` override because the default was too low, which is itself evidence
|
|
246
|
+
the default was wrong rather than merely conservative. `DEFAULT_NO_OUTPUT_BACKSTOP_MS` is now
|
|
247
|
+
`300000`, and the now-redundant CI override plus its explanatory comment are deleted from
|
|
248
|
+
`council-review.yml`. The owner's separate `900000` override, held locally, is untouched — no such
|
|
249
|
+
setting exists anywhere in the tracked tree.
|
|
250
|
+
⚠️ **The retry path doubles this window**, so a Stage-1 retry now waits up to 600 s (was 240 s)
|
|
251
|
+
before its own backstop fires, clamped to the leg timeout. `docs/configuration.md`,
|
|
252
|
+
`docs/troubleshooting.md`, `docs/usage.md` and `src/sidecar/models-probe.js`'s docblocks were
|
|
253
|
+
corrected in the same change, as were the tests that asserted the old numbers.
|
|
254
|
+
⚠️ **The 600 s worst case is a deliberate trade, not an oversight**, and it is arithmetic:
|
|
255
|
+
`min(2 × 300000, 900000) = 600000`, so the 15-minute leg-timeout clamp does **not** bind and the
|
|
256
|
+
failure CLASS stays `NO_OUTPUT_BACKSTOP` rather than degrading into an ordinary timeout.
|
|
257
|
+
Worst-case silent-leg latency on a retry is therefore 10 minutes. That is the price of
|
|
258
|
+
accommodating models that legitimately take minutes to first token; the alternative is killing
|
|
259
|
+
slow-but-healthy legs.
|
|
260
|
+
- **The live-probe docs no longer claim a fired backstop proves the endpoint accepted the
|
|
261
|
+
request.** A `NO_OUTPUT_BACKSTOP` shows only that no output, reasoning or tool call arrived in
|
|
262
|
+
the window — a stalled gateway or a dropped connection fires it just as readily.
|
|
263
|
+
`accepted-but-silent` is the classification's NAME, not a fact about the endpoint. Corrected in
|
|
264
|
+
`docs/usage.md` **and** in `src/sidecar/models-probe.js`'s docblock, which stated the same thing.
|
|
265
|
+
⚠️ Pre-existing wording in both places; this release did not introduce it.
|
|
266
|
+
- **Findings in the reliability ledger are now attributed to the seat that actually raised them,
|
|
267
|
+
instead of being concentrated onto one row per alias.** On a twin bench whose two seats resolve
|
|
268
|
+
to different executables, the ledger used to hand *every* finding raised by that alias to the
|
|
269
|
+
first of its two rows, leaving the second reading zero raised findings and a null confirm rate
|
|
270
|
+
regardless of what that seat actually did. Each finding is now credited to the pair group whose
|
|
271
|
+
own reliability data carries the seat that raised it; a finding that cannot be resolved to a
|
|
272
|
+
specific seat (no seat information at all, or a seat that matches no group's own data) still
|
|
273
|
+
concentrates on the first row, exactly as before. The row set itself is unchanged — a run that
|
|
274
|
+
never repeats a model alias is byte-identical.
|
|
275
|
+
- **`findings[].location` and `findings[].claim` submitted through the `amicus_council_tally` MCP
|
|
276
|
+
tool now survive into `tally.json`.** The tool's findings schema silently stripped a
|
|
277
|
+
hand-assembled finding's `location` before tally ever ran it; separately, tally's own output
|
|
278
|
+
step was dropping both `location` and `claim` regardless of what validation let through, on the
|
|
279
|
+
command-line path too. Both are fixed together. ⚠️ This closes the gap as far as `tally.json` —
|
|
280
|
+
neither field is forwarded further, into `verdict.json`; that remains a separate, filed,
|
|
281
|
+
undecided change.
|
|
282
|
+
- **A headless leg's run document now carries its real OpenCode session id instead of always
|
|
283
|
+
`null`.** The field was already promised by the run schema and already set on the interactive
|
|
284
|
+
path, but the headless path never assigned it, so every headless leg — the common case — carried
|
|
285
|
+
a null session id no matter how the leg actually went. It is now set on both normal outcomes and,
|
|
286
|
+
measured rather than assumed, on the exception path too, including the specific case where no
|
|
287
|
+
session was ever created. A leg's on-disk record now carries the id the same way it already
|
|
288
|
+
carries its status and usage figures, so a session-less leg's record is unaffected. Piece 1 of a
|
|
289
|
+
larger, multi-piece fix — reading and resolving session ids from provider logs is separate,
|
|
290
|
+
future work.
|
|
291
|
+
|
|
292
|
+
- **The chair packet now names seats on a bench that repeats a model alias, so the chair can
|
|
293
|
+
finally tell two same-model reviewers apart.** `chair-packet.md` is the one artifact a paid chair
|
|
294
|
+
reads as authoritative, and every identity its three review/ranking/adjudication blocks wrote was
|
|
295
|
+
alias-space — the review header printing the leg's reported model input, which can be a resolved
|
|
296
|
+
id. On a bench running the same model twice, that made the packet internally unreconcilable: the
|
|
297
|
+
chair was handed a deterministic tier count of one `Confirmed` — a count that only makes sense if
|
|
298
|
+
two different reviewers agreed — beside two adjudication lines both reading `deepseek:`, with
|
|
299
|
+
nothing anywhere in the document able to say which was which. The report's adjudication matrix and
|
|
300
|
+
the Workspace's had already moved to seat identity, so the human-facing artifact and the
|
|
301
|
+
model-facing one disagreed.
|
|
302
|
+
All three identity-bearing blocks now resolve the seat: the **review headers**
|
|
303
|
+
(`--- Review by deepseek#2 ---`), the **peer-rankings** block — both the judge it is keyed by and
|
|
304
|
+
the ranked names inside it — and the **adjudication** lines. The ranked names are matched
|
|
305
|
+
position by position, so a tie stays a tie and any name the run could not resolve to a seat keeps
|
|
306
|
+
its alias rather than turning into a blank.
|
|
307
|
+
⚠️ **On every bench that does not repeat an alias the packet is byte-identical** — by
|
|
308
|
+
construction, because the seat is absent wherever it would equal the alias, and pinned by a
|
|
309
|
+
whole-string equality test on a unique-alias bench. The review header needed more care: the
|
|
310
|
+
name it prints is the model input a leg reported, which can be the fully resolved model id even
|
|
311
|
+
when nothing about the bench is ambiguous, so the seat is deliberately **withheld** there unless
|
|
312
|
+
it actually differs — rather than assumed to be identical. A `claude` review block still reads
|
|
313
|
+
`--- Review by claude ---`: Claude's review carries no seat and is deliberately left alone.
|
|
314
|
+
This changes what the chair reads, which can change what it concludes, on twin benches only.
|
|
315
|
+
|
|
316
|
+
### Changed
|
|
317
|
+
|
|
318
|
+
- **A `--council` preset member with stray whitespace now runs instead of being silently dropped.**
|
|
319
|
+
`classifyCouncilMembers` (`src/utils/config.js`) trims each preset member **before** it is looked
|
|
320
|
+
up. The two spellings hit **different** gates: a padded **alias** (`"gpt "`) now reaches the alias
|
|
321
|
+
table clean, and a padded **full id** (`"openai/gpt-5 "`) now reaches the **model-catalog** lookup
|
|
322
|
+
clean — a full id short-circuits on `member.includes('/')` and never touches the alias table at
|
|
323
|
+
all. `--models` already
|
|
324
|
+
trimmed on both of its spellings (`sidecar/fanout-validate.js :: parseModelsList`,
|
|
325
|
+
`cli-council-run-bench.js :: parseList`), so the same stray space was benign on one flag and fatal
|
|
326
|
+
on the other: on `--council` it turned a typo into a dropped member and a **degraded exit (2)** —
|
|
327
|
+
`run.js`'s `dropped-members` note says so verbatim (*"the bench is smaller than the preset
|
|
328
|
+
requested; the run will exit degraded (2)"*).
|
|
329
|
+
⚠️ **Which presets does this actually affect? Only hand-edited ones — stated plainly rather
|
|
330
|
+
than left to be discovered.** `amicus council save` already trimmed on the way IN
|
|
331
|
+
(`council/presets-cli.js:34`), and the only other writer of `councils` in the config (the seeded
|
|
332
|
+
`free` council) builds its members from alias keys, which cannot carry padding. So a
|
|
333
|
+
whitespace-padded member reaches `config.json` only if you edited that file by hand — which is
|
|
334
|
+
exactly the case that used to fail, silently and fatally, while the same typo on `--models` was
|
|
335
|
+
harmless.
|
|
336
|
+
⚠️ **The dominant effect is RESURRECTION, not de-duplication: a member that is dropped today
|
|
337
|
+
starts RUNNING, which is a new paid leg.** Measured over the six shapes a padded member can take
|
|
338
|
+
(the configured `members` × whether the model-catalog cache is populated). **All six change:**
|
|
339
|
+
|
|
340
|
+
| `members` | catalog | BEFORE | AFTER |
|
|
341
|
+
|---|---|---|---|
|
|
342
|
+
| `['openai/gpt-5 ', 'openai/gpt-5']` | present | `["openai/gpt-5"]`, 1 dropped | `["openai/gpt-5","openai/gpt-5"]` |
|
|
343
|
+
| `['openai/gpt-5 ', 'openai/gpt-5']` | empty | `["openai/gpt-5 ","openai/gpt-5"]` | `["openai/gpt-5","openai/gpt-5"]` |
|
|
344
|
+
| `['openai/gpt-5 ']` | present | **`[]` — nothing runs** | `["openai/gpt-5"]` |
|
|
345
|
+
| `['openai/gpt-5 ']` | empty | `["openai/gpt-5 "]` | `["openai/gpt-5"]` |
|
|
346
|
+
| `['gpt ']` | empty | **`[]` — nothing runs** | `["gpt"]` |
|
|
347
|
+
| `['gpt ', 'gpt']` | empty | `["gpt"]`, 1 dropped | `["gpt","gpt"]` |
|
|
348
|
+
|
|
349
|
+
Counting the LENGTH of the resulting bench: **four of the six gain a leg that was not launched
|
|
350
|
+
before** (rows 1, 3, 5, 6), and **two of those four go from an empty bench to a running one**
|
|
351
|
+
(rows 3 and 5). Rows 2 and 4 keep their leg count and change the string that is launched. Only
|
|
352
|
+
row 2 is the twin-merge this was originally filed as.
|
|
353
|
+
⚠️ **Knock-on — a padded preset becomes a REAL twin bench**, so `seats.js :: buildSeats` mints
|
|
354
|
+
`alias#N` and every seat-keyed behaviour in this release switches on for it. Proved from the
|
|
355
|
+
artifacts rather than from `buildSeats`: `meta.seats` is `['gemini#1','gemini#2']`, the run writes
|
|
356
|
+
`review-gemini-1.md`/`review-gemini-2.md` and `judge-gemini-1.md`/`judge-gemini-2.md`, and
|
|
357
|
+
`review-gemini.md` is **absent** — against a control with the same padding and no collision, which
|
|
358
|
+
emits no `seats` key at all. **Artifact filenames change**, which matters to anything scripted
|
|
359
|
+
against them.
|
|
360
|
+
- `models` carries the **trimmed** value; `dropped` and `droppedMembers` keep the member **raw**,
|
|
361
|
+
byte-for-byte as configured, so the offending string is still findable in your own config.
|
|
362
|
+
- An all-whitespace member trims to `''`, which no alias names, so it is dropped exactly as before
|
|
363
|
+
— measured identical either side of the change.
|
|
364
|
+
- **Street-cred rows in `council report`'s output are now labelled `seat || model`**, in both
|
|
365
|
+
renderers (`report-md.js :: renderMd`, `report-html.js :: renderHtml`). On a twin bench the two
|
|
366
|
+
rows read `gemini#1` / `gemini#2` instead of `gemini` twice with different numbers under one
|
|
367
|
+
identical name — which is what a padded preset now produces. A bench with no repeated alias is
|
|
368
|
+
byte-identical. ⚠️ **The Council Workspace's street-cred table still labels
|
|
369
|
+
from the model alias**, so on a twin bench the report and the Workspace now disagree there —
|
|
370
|
+
filed, not fixed; recorded in `docs/council.md`.
|
|
371
|
+
|
|
372
|
+
- **The reliability ledger now records one row per (model, resolved executable) pair, not one per
|
|
373
|
+
bench slot.** Previously the row-building join was keyed by council alias and last-wins, so any
|
|
374
|
+
bench where one executable served more than one seat wrote something untrue into an append-only
|
|
375
|
+
file that is never migrated. Fixed: an alias whose seats resolved to *different* executables now
|
|
376
|
+
records both, instead of recording the last one twice and erasing the other; a mixed live/dead
|
|
377
|
+
twin keeps the live leg's `resolvedModel` and `conformance` instead of inheriting the dead seat's;
|
|
378
|
+
a twin whose seats shared one executable writes **one** row instead of two byte-identical ones;
|
|
379
|
+
and a run's `findingsRaised` finally sums correctly across its rows. A chair that is also a bench
|
|
380
|
+
seat writes one row when its chair leg and seat leg resolved to the same executable, and **two**
|
|
381
|
+
when they resolved to different ones — where previously the chair leg's resolution overwrote the
|
|
382
|
+
seat leg's. Row for row and in order, the ledger is unchanged only for benches whose aliases are
|
|
383
|
+
**distinct** *and* where **no alias contributed more than one joinable `runStats` row** — both
|
|
384
|
+
clauses are load-bearing, and a chair-on-bench run fails the second one even when it passes the
|
|
385
|
+
first (see the merged-row entry below).
|
|
386
|
+
- **`runs` in `amicus council stats` counts distinct council runs, not ledger rows.** One run on
|
|
387
|
+
`--models a,a` reported `runs: 2`; so did `--models gpt-5,openai/gpt-5` when both resolved to the
|
|
388
|
+
same executable. `runs` (and the `low-N` flag that follows it) now counts distinct `meta.runId`
|
|
389
|
+
values, retroactively for pre-existing history. Two consequences worth knowing: re-running
|
|
390
|
+
`amicus council tally input.json` without `--no-ledger` no longer double-counts `runs`, because a
|
|
391
|
+
re-tally of the same input carries the same `meta.runId` (it still appends a duplicate set of
|
|
392
|
+
rows, which **doubles the conformance histogram** — a tally, not an average. It does *not* move
|
|
393
|
+
the lifetime averages: a duplicated set of rows has the same mean as the original, measured
|
|
394
|
+
`avgStreetCredPeersOnly: 1.5` before and after a second append), and a harness that writes a
|
|
395
|
+
**constant** `runId` across genuinely different runs will pin that group at `runs: 1` forever. An
|
|
396
|
+
empty-string or numeric `runId` is not treated as an identity — each such row counts individually.
|
|
397
|
+
- **⚠️ The ledger-promoted fallback chair can change on existing history.** When the requested chair
|
|
398
|
+
fails twice, `amicus council run` promotes the best-street-cred non-bench model from the ledger.
|
|
399
|
+
Correcting the ledger's model of history re-ranks it, so on a ledger that already contains any
|
|
400
|
+
bench where one executable served more than one seat, the promoted chair can differ from what the
|
|
401
|
+
same history would have promoted before. Two independent randomised sweeps put this in the low
|
|
402
|
+
tens of percent of such ledgers, but neither harness ships here, so treat the shape — not a rate —
|
|
403
|
+
as the claim. Three causes, each with a deterministic case pinned in `tests/council/ledger.test.js`:
|
|
404
|
+
the join fix moving which group wins, the emission-order change moving which alias is launched
|
|
405
|
+
(the `gpt-5, openai/gpt-5, gpt-5` bench flips the launched name from the alias to the raw
|
|
406
|
+
executable id if the anchor is wrong), and the `runs` change. The old values were derived from
|
|
407
|
+
erased and double-counted rows; the new ones are not "wrong differently", but they are different. Related: `avgStreetCredPeersOnly`, `lifetimeConfirmRate`
|
|
408
|
+
and `lifetimeFactErrorRate` move too — each is a mean over a group's rows, and dropping a
|
|
409
|
+
duplicate row moves a mean whenever the group's remaining rows disagree (on
|
|
410
|
+
`--models gpt-5,openai/gpt-5,gpt-5` all resolving to one executable, `avgStreetCredPeersOnly`
|
|
411
|
+
goes 1.333 → 1.5). The conformance histogram moves for the same reason, but it is a **tally**,
|
|
412
|
+
not an average — nothing divides it; it simply counts one row fewer.
|
|
413
|
+
- **⚠️ The fallback chair can also go from promoted to absent** — rarer than the re-ranking above,
|
|
414
|
+
and pinned as a shape rather than a rate. Chair promotion excludes any aggregate whose key or
|
|
415
|
+
aliases appear on the bench; now that an executable legitimately carries every alias that really resolved to it, a bench
|
|
416
|
+
containing one of those aliases excludes the whole group. That is the exclusion working correctly
|
|
417
|
+
on newly-accurate data, but the visible outcome is a run that gives up on the chair, writes
|
|
418
|
+
`overallVerdict: null` and exits 2 where it previously synthesised a verdict.
|
|
419
|
+
- **Merged rows report the worst conformance and any chair flag.** When one executable served
|
|
420
|
+
several of a bench's seats, the resulting single row records the **worst** `conformance` of them
|
|
421
|
+
(a seat that came back `unstructured` is no longer recorded as `clean`) and `wasChair: true` if
|
|
422
|
+
**any** of them chaired. `role` still takes the last contributing row's value, scoped to that same
|
|
423
|
+
(model, executable) pair. ⚠️ This is reachable
|
|
424
|
+
from plain `amicus council tally` input, not only the engine: the documented shape puts the chair
|
|
425
|
+
on the bench, so a hand-assembled record that also carries a `role: 'chair'` row hits it — a chair
|
|
426
|
+
whose seat row was `unstructured` now reads `unstructured`
|
|
427
|
+
where it previously read the chair leg's `clean`. Conversely, on an alias whose seats resolved
|
|
428
|
+
*differently*, `wasChair` no longer propagates to the seat row that did not chair.
|
|
429
|
+
**Later in the same release, both `role` and `conformance` stopped counting a chair-synthesis leg
|
|
430
|
+
at all, while the pair also holds a bench leg** — a chair leg is a different contract from a bench
|
|
431
|
+
review (prose plus a verdict line, not findings JSON), so a seat's recorded role and conformance no
|
|
432
|
+
longer depend on whether that model also happened to chair. `wasChair` is untouched: still
|
|
433
|
+
any-wins over the pair's whole row set, chair leg included. Consequence, disclosed rather than
|
|
434
|
+
repaired: the worked example above still holds in the direction shown (bench `unstructured` + chair
|
|
435
|
+
`clean` → `unstructured`), but its reverse no longer does — bench `clean` + chair `unstructured`
|
|
436
|
+
now reads `clean`, so a broken chair synthesis by a model that also sits on the bench can vanish
|
|
437
|
+
from `amicus council stats`'s lifetime conformance histogram; only `wasChair: true` still shows
|
|
438
|
+
that it chaired. A pair of chair legs only, with no bench leg to prefer, is unaffected.
|
|
439
|
+
- **A mixed live/dead twin now shows an extra permanent line in `amicus council stats`** — the
|
|
440
|
+
executable-keyed group plus a `legacy` alias-keyed one for the leg-less seat, where there was
|
|
441
|
+
previously one line. The legacy line accrues `runs`, clears `low-N` at three runs, and carries
|
|
442
|
+
zero findings. Its street cred is no longer borrowed from its live twin: street cred is seat-keyed
|
|
443
|
+
now (see below), and a seat that never ran a leg never appears in any judge's ranking, so its own
|
|
444
|
+
seat-keyed row resolves both numbers to `null` instead of adopting its live twin's — so the legacy
|
|
445
|
+
line can no longer outrank the executable it routes to for the fallback chair (that promotion
|
|
446
|
+
excludes any group whose average street cred is not a number).
|
|
447
|
+
- **Council seats are validated before any paid leg.** `amicus council run` now refuses to start
|
|
448
|
+
when `--critic` names a model that is not on the bench, when `--critic <alias>` is ambiguous
|
|
449
|
+
because that alias occupies more than one bench seat (remove the duplicate entry, or use two
|
|
450
|
+
distinct aliases), or when two bench entries would write the same `review-<name>.md` **because one
|
|
451
|
+
of them is a disambiguated `alias#N` seat** (e.g. `--models deepseek,deepseek,deepseek-2`). A
|
|
452
|
+
collision between two distinct aliases that merely sanitize alike (`vendor/a` vs `vendor?a`) still
|
|
453
|
+
starts, still runs, and is surfaced by the run-integrity banner instead. The off-bench critic was
|
|
454
|
+
already rejected by the CLI and MCP handlers; the engine now guards it too, closing the gap for
|
|
455
|
+
in-process `require()` callers of `runCouncil`, where it previously launched a leg the run's own
|
|
456
|
+
model roster never mentioned. Bench **shape** is unaffected for benches whose aliases are distinct
|
|
457
|
+
and contain no `#`; the critic guards apply to any bench.
|
|
458
|
+
- **`--critic` together with `--lenses` is now refused before any paid leg.** The same pre-spend
|
|
459
|
+
seat validation now rejects passing both flags at once, closing the same in-process-caller gap:
|
|
460
|
+
both the CLI and MCP handlers already rejected this pair, so callers going through either see no
|
|
461
|
+
change. Only a direct in-process `require()` call passing both flags is newly refused.
|
|
462
|
+
- **`--lenses` now assigns lenses by seat, not by first-matching alias.** Under `--lenses`, a bench
|
|
463
|
+
that repeats an alias now gives each seat its own lens: `--models a,a --lenses risk,cost`
|
|
464
|
+
previously gave both seats `lens:risk`, because the role lookup resolved a seat by
|
|
465
|
+
`o.models.indexOf(alias)`, which always returns the first twin's index. It now resolves by seat
|
|
466
|
+
position, so the second twin correctly gets `lens:cost`. Benches with no repeated alias are
|
|
467
|
+
unaffected.
|
|
468
|
+
- **A seat whose leg never came back is no longer silent.** Previously a partial wave return —
|
|
469
|
+
some legs launched, one seat's result never arrived — dropped that seat with no note, no row,
|
|
470
|
+
and exit 0. It is now retried once, like any other Stage-1 loss (costing one extra retry leg),
|
|
471
|
+
and — only if the retry does not recover it — announced on the new `seat-unbound` degrade
|
|
472
|
+
channel, so the run now exits 2 instead of silently reporting success.
|
|
473
|
+
- **A bench that repeats an alias now retries and files both dead seats, not one.** Where two
|
|
474
|
+
seats sharing an alias both die, the run now produces two retry legs, two heals, and two review
|
|
475
|
+
files instead of collapsing to a single retry and a single clobbered file. Reviews are written
|
|
476
|
+
per seat as `review-<seat>.md` (e.g. `review-deepseek-1.md` / `review-deepseek-2.md`).
|
|
477
|
+
- **Cross-review and the debate round are now bound to seats too — and a bench that repeats an
|
|
478
|
+
alias pays for more legs because of it.** Stage-2 judging writes `judge-<seat>.md`, and a
|
|
479
|
+
`--debate` round writes `rebuttal-<seat>.md` and `revote-<seat>.md`, so two seats sharing an
|
|
480
|
+
alias no longer overwrite each other's file. The extra cost is **up to two** billed legs per
|
|
481
|
+
duplicated pair per debate round, and the increase has two independent halves: the defense wave
|
|
482
|
+
now launches one solo per raising **seat** where it previously launched one per raising alias,
|
|
483
|
+
and the re-vote wave launches one leg per disputing **seat** where it previously launched one
|
|
484
|
+
per disputing alias. Either extra leg can draw its own bounded repair solo, so the worst case is
|
|
485
|
+
four. `run.json`'s `debate` summary counts the re-vote half (`revoteJudges` rises accordingly)
|
|
486
|
+
and has **no counter for the defense half**, so read this entry rather than that object when
|
|
487
|
+
estimating what a duplicate bench costs. A bench whose aliases are all distinct launches exactly
|
|
488
|
+
the legs it did before and writes exactly the filenames it did before.
|
|
489
|
+
⚠️ **That "up to four" bound is superseded later in this release, and it is now a floor rather
|
|
490
|
+
than a ceiling.** It was computed against a debate round that *already existed* and merely gained
|
|
491
|
+
legs. The seat-aware peer filter below moves findings into and out of `Contested`/`Disputed`,
|
|
492
|
+
which is exactly what `nothingToDebate` counts — so on a bench that repeats an alias the debate
|
|
493
|
+
round can now **come into existence on a run that previously skipped it entirely**, taking a run
|
|
494
|
+
from **zero** billed debate legs to two-to-four. See "the seat-aware peer filter changes what is
|
|
495
|
+
paid for" below for the measured shape and the exit-code consequence. Benches with no repeated
|
|
496
|
+
alias are still unaffected.
|
|
497
|
+
- **A duplicated seat's re-vote is no longer silently dropped.** Two defects, both live before
|
|
498
|
+
this release, combined to lose it: the disputing-judge list de-duplicated by alias, so two seats
|
|
499
|
+
that both disputed a finding got **one** re-vote leg between them; and that re-vote was then
|
|
500
|
+
matched to an adjudication by alias, so it replaced whichever of the two rows came first and
|
|
501
|
+
left the other standing. Disputing seats are now de-duplicated by seat, and the replacement
|
|
502
|
+
joins on the seat, so each seat re-votes for itself and each seat's own verdict is the one
|
|
503
|
+
replaced.
|
|
504
|
+
- **A judge's Stage-2 conformance no longer overwrites its twin's.** The merge of each judge's
|
|
505
|
+
conformance back onto its reviewing seat was keyed on the alias, so a bench that repeats one
|
|
506
|
+
collapsed both seats onto a single entry and the last judge's conformance won for both. The
|
|
507
|
+
merge is now seat-keyed, falling back to the alias for a judge leg that bound to no seat.
|
|
508
|
+
- **A re-vote repair for a model whose alias contains `/` no longer nests its session directory
|
|
509
|
+
three levels deep.** The repair leg's wave id embedded the alias verbatim, so
|
|
510
|
+
`openrouter/deepseek/deepseek-chat` produced
|
|
511
|
+
`.claude/amicus_sessions/<runId>-rv-openrouter/deepseek/deepseek-chatr` instead of one directory.
|
|
512
|
+
The id is now filename-sanitized. This one was never about duplicate benches — it fired for any
|
|
513
|
+
alias containing a path separator whose re-vote needed a repair.
|
|
514
|
+
- **A Stage-2 wave that loses or cannot attribute a judge leg now exits 2 instead of 0.** Stage 2
|
|
515
|
+
binds its judge legs to seats. A returned leg matching no roster slot is announced on the
|
|
516
|
+
`seat-unbound` degrade channel; so is each seat that a short-returning wave never accounted for
|
|
517
|
+
— but only when that wave returned at least one leg *and* produced no unattributable one, because
|
|
518
|
+
an orphan already names the same failure, and a wave that returned nothing at all is announced
|
|
519
|
+
on the louder `thin-cross-review` channel instead. Whichever branch fires, the run now exits
|
|
520
|
+
degraded (2) where it previously exited 0 as long as at least two judges still parsed.
|
|
521
|
+
Symmetric with the Stage-1 change above.
|
|
522
|
+
- **`debate.json` changes key space on a bench that repeats an alias — and only there.**
|
|
523
|
+
`findings[].raiser` is now the raising **seat's** id, and each `revotes[]` row keeps its
|
|
524
|
+
alias-valued `judge` and gains a `seat` field only when the two differ. The chair addendum's
|
|
525
|
+
`priorVerdicts` and `revotes` maps are keyed seat-side to match, so they still agree line for
|
|
526
|
+
line. `tally-input.json` and `tally.json` gain `meta.seats` (the run's seat table, `{id, alias,
|
|
527
|
+
role, lens, position}` per bench seat), `adjudications[].seat`, `findings[].raiserSeat` and
|
|
528
|
+
`runStats[].seat` — each emitted only when the seat id differs from the alias — plus
|
|
529
|
+
`findings[].sameModelCorroboration`, emitted only when it is true, which likewise requires a
|
|
530
|
+
repeated alias. On a `--debate` run `tally-provisional.json` carries `meta.seats` too.
|
|
531
|
+
`verdict.json` gains the matching set: a top-level `seats` table beside `seatLoss`, plus
|
|
532
|
+
`findings[].raiserSeat` and `findings[].sameModelCorroboration` (`adjudications[].seat` and
|
|
533
|
+
`runStats[].seat` arrive without a verdict-side change — both arrays are carried through by
|
|
534
|
+
reference). A reader holding **just the verdict** can now tell which of two same-alias seats
|
|
535
|
+
raised a finding, and can resolve every `alias#N` id in the document against its own seat
|
|
536
|
+
table. On a bench with no repeated alias every seat id **is** its alias, so none of
|
|
537
|
+
these documents changes shape at all there — with one narrow exception, of *value* rather than
|
|
538
|
+
shape: the re-vote **repair** leg's wave id is now filename-sanitized (the `/`-nesting fix
|
|
539
|
+
above), so a unique-alias bench whose alias contains a character sanitization rewrites *and*
|
|
540
|
+
whose re-vote actually needed a repair records a different id for that one leg in `run.json`'s
|
|
541
|
+
`stages[].waveIds` and in the matching `runStats` row of `tally-input.json`, `tally.json` **and
|
|
542
|
+
`verdict.json`** — the verdict carries the tally's `runStats` array through by reference, so it
|
|
543
|
+
shows the changed id too. No other leg, and no bench whose aliases are already filename-safe, is
|
|
544
|
+
affected.
|
|
545
|
+
- **Known limitation: a partial-return seat loss is recorded in `run.json` but not yet reflected
|
|
546
|
+
in `verdict.json`.** The loss lands in `stage.deadWaves` (as a `partial` entry) and in
|
|
547
|
+
`degrades[]` (on the new `seat-unbound` channel above), and the run exits 2 — but
|
|
548
|
+
`verdict.json`'s `seatLoss.deadBenchSeats` does not name the seat, because `verdict.js`'s
|
|
549
|
+
`deriveSeatLoss` filters `degrades[]` down to the `dead-wave`/`dead-leg` channels only, and a
|
|
550
|
+
`seat-unbound` loss matches neither. The seat is not silently dropped from the run's own
|
|
551
|
+
record, only from the summary readers usually check first. **Still open at the end of this
|
|
552
|
+
release.** The seat work later in this stack gave `verdict.json` a seat *table* and seat-stamped
|
|
553
|
+
findings and `runStats` rows, but it did not touch `deriveSeatLoss`'s channel filter, so
|
|
554
|
+
`seatLoss.deadBenchSeats` still does not name a `seat-unbound` loss. Closing it is filed, not
|
|
555
|
+
scheduled here.
|
|
556
|
+
- **The Council Workspace now opens per-seat artifacts on a bench that repeats an alias.** The
|
|
557
|
+
allowlist is built from `run.seats` rather than a de-duplicated bench, so
|
|
558
|
+
`--models deepseek,deepseek,gemini` lists `review-deepseek-1.md` / `-2.md`, the matching `judge-`
|
|
559
|
+
files, and — on a `--debate` run — the `rebuttal-`/`revote-` pair, instead of a
|
|
560
|
+
`review-deepseek.md` that bench never writes. Previously neither twin file was reachable, in every
|
|
561
|
+
artifact family the run wrote.
|
|
562
|
+
⚠️ **Two distinct aliases that sanitize to the same name are still one physical file.** On
|
|
563
|
+
`--models "vendor/a,vendor?a,vendor/a"` the allowlist attributes `review-vendor-a.md` to the
|
|
564
|
+
sorted-first alias while the file may physically hold the other's review — a misattribution rather
|
|
565
|
+
than a refusal, and the run-integrity banner says so.
|
|
566
|
+
A file an *orphaned* leg wrote under its alias stays readable but is attributed to no seat:
|
|
567
|
+
`run.json` cannot say which seat produced it, and guessing is the silent mis-attribution the
|
|
568
|
+
seat spine exists to prevent. Where such a name collides with another seat's own artifact, the
|
|
569
|
+
run-integrity banner says so — and only the artifact *kinds* that orphan could actually have
|
|
570
|
+
written lose their attribution. A leg that orphaned in the cross-review wave costs the colliding
|
|
571
|
+
seat its `judge-` file and not its `review-`, because that leg's own review landed under a seat
|
|
572
|
+
name; a leg that orphaned in Stage 1 costs it both, because a Stage-1 orphan is re-admitted to
|
|
573
|
+
the cross-review under a placeholder and writes an alias-named judge file too. The list of
|
|
574
|
+
readable names is not narrowed either way: a name is dropped from *attribution*, never from the
|
|
575
|
+
allowlist. Benches whose aliases are all distinct are byte-for-byte unaffected — every name they
|
|
576
|
+
write is on the list, and the list is unchanged.
|
|
577
|
+
- **The Council Workspace's seats panel now shows one live row per seat on a bench that repeats
|
|
578
|
+
an alias, instead of one usable row beside a frozen ghost.** The terminal seat rows were keyed
|
|
579
|
+
on `alias:role`, so two seats of one alias collided. The panel rendered both rows on its first
|
|
580
|
+
repaint and then **froze the first one permanently**: every later repaint resolved the shared
|
|
581
|
+
key to the *last* row, so both seats wrote into it while the first was never matched again and
|
|
582
|
+
never removed. What you saw was a stale row that stopped at its first-tick values, beside a row
|
|
583
|
+
flickering between two seats' data — with no error, no banner, and no indication either was
|
|
584
|
+
wrong. Rows are now keyed on the seat, which the tally already recorded and which the run
|
|
585
|
+
detail already carried. Benches whose aliases are all distinct render identically: the seat is
|
|
586
|
+
only recorded when it differs from the alias, so every row resolves to the same seat it did
|
|
587
|
+
before. (The row's internal DOM key changed encoding in the same change; nothing user-visible
|
|
588
|
+
reads it.)
|
|
589
|
+
- **The "↻ retried once" badge now marks the seat that was actually retried, not every seat
|
|
590
|
+
sharing its alias — where the run records enough to tell them apart.** ⚠️ **It cannot always
|
|
591
|
+
tell.** Every dead arm now records a seat id where the run could bind one: a leg-origin retry
|
|
592
|
+
carries `firstFailure.seatId`, a retry wave that died wholesale carries its own `seatId`, and a
|
|
593
|
+
wave-origin loss carries a `seats[]` array index-parallel with `models[]`. What is still
|
|
594
|
+
alias-only is a slot the producer could not identify at all, plus the two arms that route to the
|
|
595
|
+
`seat-unbound` channel, which this surface has never read. **When only an alias is recorded,
|
|
596
|
+
every seat sharing that alias is badged.** That is deliberate: the record does not say which seat failed, so nothing downstream
|
|
597
|
+
can attribute it, and a badge one seat too wide is visible and self-correcting where a missing
|
|
598
|
+
badge would be silent. Marking the seat exactly would need the producer to stamp the seat id on
|
|
599
|
+
every channel — a change with its own blast radius, filed rather than smuggled in here.
|
|
600
|
+
- **⚠️ The peers-only filter now excludes the raiser by SEAT, so findings on a bench that repeats
|
|
601
|
+
an alias change tier — in BOTH directions.** Before this release the filter compared council
|
|
602
|
+
aliases, so on `--models deepseek,deepseek,gpt` the *second* deepseek seat's vote was discarded
|
|
603
|
+
along with the raiser's own: a finding with one genuine corroborating peer reported as the
|
|
604
|
+
no-signal tier. The filter now compares seat ids when a vote and its finding both carry one, and
|
|
605
|
+
falls back to comparing aliases when either does not — or, when the finding names no raiser at
|
|
606
|
+
all, to keeping every **named** judge (see the raiser-less fix under **Fixed**). This is a
|
|
607
|
+
behaviour change by design, and
|
|
608
|
+
it is not a one-way improvement — measured on `['deepseek','deepseek','gpt']`, a finding raised
|
|
609
|
+
by `deepseek#1`:
|
|
610
|
+
|
|
611
|
+
| scenario | before | after |
|
|
612
|
+
|---|---|---|
|
|
613
|
+
| twin agrees, gpt agrees | `a1/d0` Confirmed (thin) | `a2/d0` Confirmed (**solid**) |
|
|
614
|
+
| twin agrees, gpt silent | `a0/d0` **Singleton** (thin) | `a1/d0` **Confirmed** (thin) |
|
|
615
|
+
| twin agrees, gpt disputes | `a0/d1` Contested (thin) | `a1/d1` Contested (**solid**) |
|
|
616
|
+
| twin votes neutral, gpt silent | `a0/d0/n0` Singleton | `a0/d0/`**`n1`** Singleton (tier unchanged) |
|
|
617
|
+
| twin **disputes**, gpt agrees | `a1/d0` **Confirmed** (thin) | `a1/d1` **Contested** (solid) |
|
|
618
|
+
| twin **disputes**, gpt disputes | `a0/d1` **Contested** (thin) | `a0/d2` **Disputed** (solid) |
|
|
619
|
+
|
|
620
|
+
The ceiling case is `--models deepseek,deepseek,deepseek`, where every seat shares one alias: the
|
|
621
|
+
whole cross-review used to be discarded — every finding `Singleton`, `{a:0,d:0,n:0}`, `thin`,
|
|
622
|
+
ledger `confirmRate` **0** — and now reads `Confirmed`, `solid`, ledger `confirmRate` **1**.
|
|
623
|
+
⚠️ **The demotions have a permanent cost.** `Disputed` is the numerator of the ledger's
|
|
624
|
+
`factErrorRate`, which feeds `lifetimeFactErrorRate` in `amicus council stats`; the ledger is
|
|
625
|
+
append-only and is never migrated, so a twin's dispute that newly demotes a finding writes a
|
|
626
|
+
reliability penalty that stays. Benches whose aliases are all distinct are **unaffected** — every
|
|
627
|
+
seat id there is its own alias, so the seat compare and the alias compare give the same answer.
|
|
628
|
+
One deliberate non-fix, disclosed rather than hidden: on hand-assembled input where a vote's
|
|
629
|
+
alias differs from the raiser's but its *seat id* equals the raiser's seat, the vote used to
|
|
630
|
+
count as a peer and no longer does. The engine cannot produce that shape (a seat's id always
|
|
631
|
+
belongs to its own alias), so only hand-written tally input can hit it.
|
|
632
|
+
- **⚠️ The seat-aware peer filter changes what is paid for, and can flip a run's exit code with no
|
|
633
|
+
legs launched.** The debate round runs when there is anything `Contested` or `Disputed`, and the
|
|
634
|
+
filter moves findings **into and out of** that set. On a bench that repeats an alias a run can
|
|
635
|
+
therefore now launch a debate round it previously skipped entirely — a defense solo per raising
|
|
636
|
+
seat, a re-vote leg per disputing seat, and up to two bounded repairs, so **2–4 billed legs on a
|
|
637
|
+
run that previously paid nothing**. It can also move findings *out* and skip a round the previous
|
|
638
|
+
release ran. And on a run whose `--max-cost` ceiling is already spent, having something worth
|
|
639
|
+
debating is itself the trigger for the `debate-degraded` channel: the run writes a `degrades[]`
|
|
640
|
+
entry into `verdict.json` and a "What was lost" line into the report and **exits 2 where it
|
|
641
|
+
previously exited 0 silently** — without launching anything. Distinct-alias benches are
|
|
642
|
+
unaffected.
|
|
643
|
+
- **⚠️ The GitHub Action promotes twin-corroborated findings to inline PR annotations.**
|
|
644
|
+
`council-review.yml` selects `tier == "Confirmed"` for its check-run annotations and for the
|
|
645
|
+
top-level `### Confirmed findings` section of the PR comment, while `Singleton` findings stay
|
|
646
|
+
inside a collapsed `<details>`. A finding corroborated only by its own twin now clears that bar,
|
|
647
|
+
so it moves from the collapsed list to a top-level section **plus an inline annotation on the
|
|
648
|
+
diff** — the most externally visible surface amicus has. The job's pass/fail gate is
|
|
649
|
+
`fail_on`/`overallVerdict` and it already tolerates exit 2, so the degrade above does not by
|
|
650
|
+
itself fail the check.
|
|
651
|
+
- **New `findings[].sameModelCorroboration` flag on `tally.json` and `verdict.json` — and it is
|
|
652
|
+
wrong in two directions.** Emitted (`true` only, never `false`) when, after the seat-aware
|
|
653
|
+
exclusion, at least one *agreeing* peer shares the raiser's alias: the corroboration is real but
|
|
654
|
+
came from another seat of the same model, so it is not independent. ⚠️ The comparison is on the
|
|
655
|
+
**alias**, so it **misses** `--models gpt-5,openai/gpt-5` — genuinely one model under two aliases,
|
|
656
|
+
which votes carry no resolved-executable id to detect — and it **fires falsely on a split
|
|
657
|
+
alias**, one alias whose two seats happened to resolve to different executables. That second case
|
|
658
|
+
is exactly the bench the reliability-ledger fix at the top of this section was rewritten for, and
|
|
659
|
+
it is the more harmful direction: it tells a reader to discount a genuinely independent
|
|
660
|
+
cross-executable corroboration. The two documents of a single run therefore use **different
|
|
661
|
+
notions of "the same model"** — the ledger keys on `(alias, resolved executable)`, this stamp on
|
|
662
|
+
the alias alone. That is stated here rather than papered over; treat the stamp as "worth a second
|
|
663
|
+
look", never as proof.
|
|
664
|
+
- **The adjudication matrix is keyed by seat — which fixes a silent data loss.** In `council report`
|
|
665
|
+
(Markdown and HTML) and in the Council Workspace, a bench that repeats an alias now gets one
|
|
666
|
+
column per **seat**, titled `deepseek#1` / `deepseek#2`, the Raiser cell names the raising seat,
|
|
667
|
+
and the `*` marks that seat's column only. The old alias key was **last-wins**: the second seat's
|
|
668
|
+
vote overwrote the first's, so a finding whose `basis` was `a0/d1` could render as two
|
|
669
|
+
agreements, both starred — a real dispute erased from the artifact. The rendered row and the
|
|
670
|
+
finding's `basis` now agree. **Unique-alias benches are byte-identical**: without a `seats` table
|
|
671
|
+
(or with a malformed one) the **matrix** falls back to alias space whole. That is a statement
|
|
672
|
+
about this change only — the `UNATTRIBUTED` column below moves some alias-space documents on its
|
|
673
|
+
own terms. ⚠️ **That fence is the matrix's, not the whole document's, and
|
|
674
|
+
"assembled by hand" is where it stops holding.** The street-cred table labels each row from
|
|
675
|
+
`streetCred[].seat` when the row carries one — a predicate independent of `seats` — so a verdict
|
|
676
|
+
carrying seated `streetCred[]` rows beside an absent *or* malformed `seats` renders
|
|
677
|
+
`deepseek#1`/`deepseek#2` there while the matrix still reads `deepseek` twice. Measured on three
|
|
678
|
+
shapes (absent, non-array, array-of-strings), not reasoned from the code. In-process the two
|
|
679
|
+
fields are produced from the same twin bench and travel together; the split is reachable on a
|
|
680
|
+
hand-assembled or externally-supplied record, which `verdict.js :: buildVerdict`'s own docblock
|
|
681
|
+
names and which `amicus_verdict`'s `record: z.record(z.any())` accepts. A verdict written before
|
|
682
|
+
v4.8 carries no `streetCred[].seat` at all and is unaffected. The renderer change that opened this
|
|
683
|
+
is the street-cred labelling note at the top of this section.
|
|
684
|
+
**Blind mode still never shows a seat id** — a
|
|
685
|
+
seat id contains its alias — so both twins collapse to `Review A` there exactly as before.
|
|
686
|
+
- **A vote the matrix cannot attribute now renders in an `UNATTRIBUTED` column instead of vanishing.**
|
|
687
|
+
In `council report` (Markdown and HTML) and in the Council Workspace, the vote→column join now
|
|
688
|
+
**refuses** a key that identifies nothing — an empty string, a missing or non-string `judge`, or a
|
|
689
|
+
seat id or alias naming no column on the bench — and folds every such vote into one extra column,
|
|
690
|
+
headed `UNATTRIBUTED` and placed last among the judge columns. The case this was written for: a
|
|
691
|
+
judge whose Stage-2 leg never bound to its seat emits no `adjudications[].seat`, so in seat space
|
|
692
|
+
its vote keys to a bare alias no column reads. It used to count in `basis` and render nowhere —
|
|
693
|
+
the artifact and the score disagreed. It now counts in `basis` **and** renders. **`basis` is
|
|
694
|
+
unchanged either way**: this is a rendering fix, not a scoring one. The column is **conditional**
|
|
695
|
+
— it appears only on a document that actually has a vote to fold, so a document in which every
|
|
696
|
+
vote is attributable grows no column and never an empty one. ⚠️ **That last claim is scoped to
|
|
697
|
+
this change, not to the release**: the seat re-key described above is a separate, earlier change
|
|
698
|
+
in this same release and moves such documents on its own terms. All folded votes on one finding share the one cell, last-wins — the column records
|
|
699
|
+
one fact about the document rather than one per voter; a seat id is what tells them apart, and
|
|
700
|
+
supplying one is a producer-side fix. ⚠️ **Read the header as “no column on this bench”, not
|
|
701
|
+
“nobody knows who voted”** — the rule is about the column, not the voter. On a `--claude-review`
|
|
702
|
+
run the report keeps the reserved `claude` seat off its bench while the Workspace matrix keeps it,
|
|
703
|
+
so a hand-authored `judge: "claude"` vote folds in the report and lands in the `claude` column in
|
|
704
|
+
the Workspace; no engine run emits one, and reconciling the two rosters is deliberately out of
|
|
705
|
+
scope for this change. ⚠️ Not to be
|
|
706
|
+
confused with `findings[].unattributedPeerDrops`, listed earlier in this release, which counts
|
|
707
|
+
votes the **peer filter** excluded from `basis` on the raiser side and which is deliberately left
|
|
708
|
+
excluded — the drop is announced rather than counted, because nothing in the document can tell a
|
|
709
|
+
real twin's signal from the raiser's own. Different mechanism, different document, opposite effect
|
|
710
|
+
on `basis`.
|
|
711
|
+
- **`amicus_council_tally` (MCP) no longer strips the seat keys.** Its input schema now accepts
|
|
712
|
+
`meta.seats`, `findings[].raiserSeat` and `adjudications[].seat`; previously zod silently dropped
|
|
713
|
+
all three, which would have left the MCP tool permanently on the pre-fix peer-filter behaviour
|
|
714
|
+
while `amicus council tally` — a raw JSON parse with no schema — got the fix. All three are
|
|
715
|
+
declared permissively (validate the envelope, let the tally engine arbitrate shape), so **for
|
|
716
|
+
these three keys** anything the CLI accepts the MCP path accepts too, including `null`. That is
|
|
717
|
+
not true of the path as a whole: `adjudications[].verdict`, for one, is a closed `z.enum` on MCP
|
|
718
|
+
and unconstrained on the CLI. ⚠️ `location` and `claim` now reach `tally.json` on this path (see
|
|
719
|
+
**Fixed**, above) but are still not forwarded into `verdict.json` — a separate, filed change.
|
|
720
|
+
- **Two fields shipped earlier in this release stop being emitted on two bench shapes.**
|
|
721
|
+
`findings[].raiserSeat` and `adjudications[].seat` were compared against the *leg's* model input
|
|
722
|
+
rather than against the seat's own alias, so they were emitted on two benches with no repeated
|
|
723
|
+
alias at all: a bench carrying a whitespace-padded member, and a bench whose leg
|
|
724
|
+
reported no model input (where the comparison saw the resolved executable id instead).
|
|
725
|
+
⚠️ **Only one of the two shapes is still reachable.** A `--council` preset can no longer put
|
|
726
|
+
padding on a bench alias — `config.js :: classifyCouncilMembers` trims each member first (see
|
|
727
|
+
*"A `--council` preset member with stray whitespace now runs"* at the top of this section).
|
|
728
|
+
⚠️ **Nor can anything else, traced to the end.** `src/mcp-council-bench.js :: resolveBenchInput`
|
|
729
|
+
does return `input.models` untrimmed, but that is one hop, not the route: its single consumer
|
|
730
|
+
always spawns the CLI child with `--models bench.join(',')`, and the child re-parses through
|
|
731
|
+
`cli-council-run-bench.js :: parseList`, which trims — and `runCouncil` is not exported from
|
|
732
|
+
`src/index.js`. **The surviving shape is the OTHER one**: a leg that reports no model input,
|
|
733
|
+
where the comparison sees the resolved executable id. That half is live, which is why the rule and
|
|
734
|
+
the fix below are unchanged. In both
|
|
735
|
+
cases the emitted value was byte-equal to the alias, carried no information, and — until now —
|
|
736
|
+
had no seat table able to resolve it. All four seat-emitting producers now share one rule: emit
|
|
737
|
+
when the seat's id differs from **its own alias**. This is a visible change to two fields, and it
|
|
738
|
+
is a correction.
|
|
739
|
+
- **Street cred is seat-keyed, end to end.** On a bench that repeats an alias, `streetCred[]` now
|
|
740
|
+
emits **one row per seat** instead of one collapsed row per alias, each carrying the new
|
|
741
|
+
`streetCred[].seat` field (the seat id, emitted only when it differs from the row's own alias).
|
|
742
|
+
`rankings[]` gained the matching `seat` and `orderSeats` fields — a judge's own seat id, and a
|
|
743
|
+
seat-valued parallel of `order` — on the same emit-when-**different** terms, so a bench with no
|
|
744
|
+
repeated alias carries neither and its documents stay byte-identical to before. `peersOnly`'s
|
|
745
|
+
self-exclusion is now seat-conditional too: when a row and a judge both carry a seat id the engine
|
|
746
|
+
compares seats, so a twin's *other* seat now counts as a real peer instead of being excluded as
|
|
747
|
+
"the same model reviewing itself." See `council.md`'s tally-record schema for the full
|
|
748
|
+
field-by-field notes. ⚠️ Disclosed side effect, filed not fixed: the same release's ledger-join
|
|
749
|
+
change means a chair-synthesis leg's own conformance can fall out of the reliability ledger
|
|
750
|
+
entirely on a mixed bench/chair group — see the merged-rows entry above.
|
|
751
|
+
⚠️ **This is a visible change to `streetCred[]` row order, and it is a correction.** Fixing the
|
|
752
|
+
row-count defect described under Fixed, above, also moves row order on a repeated alias that is
|
|
753
|
+
**not adjacent** in `meta.models` — `--models a,b,a`, which `seats.js :: buildSeats` assigns
|
|
754
|
+
`a#1`/`b`/`a#2` without complaint, an ordinary three-seat bench, no rejection. `credSeats` now
|
|
755
|
+
pushes one row per `models` occurrence at that occurrence's own index, so the order is
|
|
756
|
+
`["a#1","b","a#2"]` where it previously read `["a#1","a#2","b"]` — the old order grouped a
|
|
757
|
+
repeated alias's rows at its *first* occurrence, an accident of the pre-fix expand-then-skip loop,
|
|
758
|
+
never a documented property. The new order already agrees with `meta.seats`, whose own `.position`
|
|
759
|
+
field is in `meta.models` order by construction (`seats.js :: buildSeats` is `aliases.map(...)`
|
|
760
|
+
over the bench). This reaches `tally.json`, `verdict.json` (`verdict.js :: buildVerdict`) and both
|
|
761
|
+
report renderers' street-cred tables (`report-md.js :: renderMd`, `report-html.js :: renderHtml`).
|
|
762
|
+
Fuzzed over 2178 engine-shaped cases (a repeated alias, adjacent or not, over every bench
|
|
763
|
+
`buildSeats` accepts without complaint): 1368 divergences from the old order, **all order-only —
|
|
764
|
+
zero content divergences, zero length-invariant violations**. Every fixture in the tree before this
|
|
765
|
+
fix kept a repeated alias adjacent, where the two orders coincide, which is why nothing caught it
|
|
766
|
+
earlier. Content is identical either way; only row order moves, and only on a non-adjacent repeat —
|
|
767
|
+
a bench with no repeated alias, or one whose repeat is adjacent, is byte-for-byte unaffected.
|
|
768
|
+
- **What seat identity does NOT cover after this release.** Four things, stated so they are not
|
|
769
|
+
discovered:
|
|
770
|
+
- **The raw `lens` text is not recoverable from the tally artifacts on a bench that does not
|
|
771
|
+
repeat an alias**, because `meta.seats` — which carries it — is emitted only when one does.
|
|
772
|
+
`runStats[].role` keeps the slug (`lens:cost`), not the text you passed. The seat *ordinal* is
|
|
773
|
+
recoverable on any bench: `meta.models` is the bench in seat order, so an alias's k-th
|
|
774
|
+
occurrence is its k-th seat.
|
|
775
|
+
- **Two peer-split seat shapes stay open**: the raiser's own Stage-1 leg orphaning, and a peer
|
|
776
|
+
twin's leg orphaning. In each, the finding carries a seat id and the vote does not (or the
|
|
777
|
+
reverse), so the seats cannot decide and the alias compare excludes the vote. It is excluded and
|
|
778
|
+
**announced** in `findings[].unattributedPeerDrops` rather than counted — read that count as "up
|
|
779
|
+
to N votes of peer signal may be missing here", never as "N are".
|
|
780
|
+
- **The Council Workspace's street-cred table is still labelled from the alias**, so on a twin
|
|
781
|
+
bench it and `council report` disagree there (see the street-cred labelling note at the top of
|
|
782
|
+
this section).
|
|
783
|
+
- **The Workspace's dead-seat rows are still alias-keyed on the critic path**, and
|
|
784
|
+
`verdict.json`'s `seatLoss.deadBenchSeats` still does not name a `seat-unbound` loss (both
|
|
785
|
+
detailed at their own entries above).
|
|
786
|
+
- Live council-run leg rows now carry the leg's seat id (`alias#N`) when the bench repeats an
|
|
787
|
+
alias, threaded from the Stage-1 roster through the fanout transport to `metadata.json` and back
|
|
788
|
+
out via the composed live doc. On a unique-alias bench nothing is written — `metadata.json` is
|
|
789
|
+
unchanged — and every live leg row reports an explicit `seat: null`. Threaded only from Stage 1's
|
|
790
|
+
initial launch; chair, **Stage 2**, debate, repair, and the Stage-1 retry wave (a separate launch
|
|
791
|
+
site, `run-retry.js`) all launch without a roster and are unchanged — a retried twin's and a
|
|
792
|
+
cross-review judge's live rows still report `seat: null`. Filed, not fixed here.
|
|
793
|
+
|
|
794
|
+
### Internal
|
|
795
|
+
|
|
796
|
+
- **The roster pad/bind/drop-placeholder core now lives in one place.** Three call sites
|
|
797
|
+
— the Stage-1 retry wave, the Stage-2 judge wave and the debate re-vote wave — each carried a
|
|
798
|
+
near-verbatim copy of the same eleven lines: pad every unidentified roster slot with a
|
|
799
|
+
position-stable placeholder carrying a unique synthetic id, bind, then drop the placeholder
|
|
800
|
+
binds so nothing is guessed. That block is now
|
|
801
|
+
`stage1-bind.js :: bindPaddedWave(waveId, rosterSource, aliasAt, legs)`, and each site keeps
|
|
802
|
+
only its own orphan/missing tail, which genuinely differs (one returns orphans to its caller,
|
|
803
|
+
one notes them and walks the unbound seats, one has no tail at all).
|
|
804
|
+
**Zero behaviour change**: no output, artifact, exit code, degrade note or `runStats` row moves.
|
|
805
|
+
The safety property this code exists for is unchanged and better pinned: placeholder ids stay
|
|
806
|
+
unique and placeholder binds never reach a seat map, a CONJUNCTION whose failure silently
|
|
807
|
+
loses a retried leg’s billed usage. Breaking the drop-filter now fails **19 tests across four
|
|
808
|
+
suites**, where the same edit reached **14 across three** before the consolidation.
|
|
809
|
+
|
|
810
|
+
- **Added a regression test asserting that `parseModelsList` preserves duplicate aliases.** One
|
|
811
|
+
re-vote leg per disputing seat depends on `--models gpt,deepseek,deepseek` producing three legs,
|
|
812
|
+
not two after deduplication; the invariant already held but nothing named it. `parseModelsList`
|
|
813
|
+
is now pinned directly and the downstream `validateFanoutModels` step is covered by its own test;
|
|
814
|
+
leg construction inside `runFanout` remains unpinned, so a dedupe introduced there would still be
|
|
815
|
+
silent. `parseModelsList` itself is byte-unchanged — this is a test plus an invariant comment, not
|
|
816
|
+
a behaviour change.
|
|
817
|
+
|
|
818
|
+
- **Five further module extractions.** `seat-space.js` (out of `artifact-names.js`),
|
|
819
|
+
`report-md.js :: renderMd` (out of `report.js`), `run-stats-entry.js :: buildRunStatsEntry`, the
|
|
820
|
+
`run-retry` split, and a set of helper moves each relocated existing code to keep modules under
|
|
821
|
+
the repo's size gate. The moves themselves change no output, artifact or exit code; where a
|
|
822
|
+
behaviour change shipped in the same PR as a move, it is listed under **Changed** or **Fixed**
|
|
823
|
+
above.
|
|
824
|
+
|
|
825
|
+
### Added
|
|
826
|
+
|
|
827
|
+
- **`doctor` gains a `sessions-index-prune` check; `--fix` removes stale `sessions-index.json`
|
|
828
|
+
rows.** `recordSession` records a `taskId -> project` entry on session start and nothing ever
|
|
829
|
+
removed one, so a deleted, renamed or moved project's rows outlived it forever, and every
|
|
830
|
+
session start paid to read/parse/mutate/stringify/write the *whole* file regardless. The new
|
|
831
|
+
check lists entries whose project path no longer resolves to a directory, reports both the stale
|
|
832
|
+
count and the distinct-project count (many task ids can share one project) when it announces work
|
|
833
|
+
to be done, and `--fix` prunes them atomically through the same write primitive `recordSession`
|
|
834
|
+
itself uses — a temp file plus a rename — reusing the announce-then-fix shape its
|
|
835
|
+
`sessions-index-tmp` sibling already established for that file's orphaned temp siblings.
|
|
836
|
+
Liveness only, never age: a five-year-old entry for a project that still exists is left alone; a
|
|
837
|
+
one-day-old entry for a deleted one is not. Among *error* conditions, only a confirmed-gone
|
|
838
|
+
`ENOENT`/`ENOTDIR` counts as stale — a permissions error or any other unreadable-but-maybe-there
|
|
839
|
+
condition leaves an entry alone rather than risk deleting a live lookup target. (A path that
|
|
840
|
+
resolves to a plain file, and an entry with no usable project value at all, are also stale — no
|
|
841
|
+
error is involved in either.) ⚠️ This closes the structural growth gap,
|
|
842
|
+
not a specific steady-state size: most of the index size first measured against this defect was
|
|
843
|
+
test residue from an already-sealed `/tmp` hermeticity leak, not something this check alone was
|
|
844
|
+
ever going to shrink back to zero.
|
|
845
|
+
⚠️ **A failure to determine is reported as a failure, not as a clean bill of health.** A
|
|
846
|
+
pre-ship review found that the check's catch-alls swallowed every exception — including a
|
|
847
|
+
programming error — making a crash indistinguishable from *"nothing to prune"*.
|
|
848
|
+
It now surfaces `status:'error'` through `doctor`'s existing `guard()` vocabulary rather
|
|
849
|
+
than returning a false all-clear, and still never throws into `doctor`. A correct-but-SILENT
|
|
850
|
+
degrade fails this project's bar as hard as a crash.
|
|
851
|
+
⚠️ **KNOWN, documented at the write site, and deliberately NOT fixed here — a pre-existing
|
|
852
|
+
read-modify-write race.** `--fix` reads the index, drops the stale ids and rewrites the whole
|
|
853
|
+
file, so a session started inside that window can lose its entry. `recordSession` performs
|
|
854
|
+
the *identical* unlocked read-modify-write, so two concurrent session starts already clobber
|
|
855
|
+
each other: the prune **inherits** the index's existing concurrency model rather than
|
|
856
|
+
introducing it. The window was narrowed to the in-memory filter loop — the write target is now
|
|
857
|
+
resolved before the read — and prune deletes only the ids it was handed.
|
|
858
|
+
⚠️ **The race is TWO-SIDED, so locking only this side would be theater.** Closing it properly
|
|
859
|
+
means locking `recordSession` too, and that lock — like the `statSync`-per-entry cost of the
|
|
860
|
+
"prune on write" alternative — is exactly the per-session-start cost this doctor-check design was
|
|
861
|
+
chosen to avoid. `--fix` and `recordSession` both perform the identical unlocked
|
|
862
|
+
read-modify-write on the same file today, independent of this change; nothing here makes that
|
|
863
|
+
worse. `src/utils/session-lock.js` already provides atomic PID/staleness lock-file primitives
|
|
864
|
+
(used today for per-session-dir start, resume and continue, not for this file) and is the natural
|
|
865
|
+
home for a future `sessions-index.json` lock or compare-and-swap — but it is **not** wired in
|
|
866
|
+
here; that remains its own change.
|
|
867
|
+
|
|
868
|
+
- **New default model alias: `inkling`** (`openrouter/thinkingmachines/inkling`), bringing the
|
|
869
|
+
shipped alias table to 21. It resolves for `--models`, `--council` presets, `amicus models` and
|
|
870
|
+
the setup wizard's alias list like any other default. Pinned to the flagship rather than
|
|
871
|
+
`inkling-small`, and deliberately not to the `:batch` variant — deferred completion is wrong for
|
|
872
|
+
an interactive council leg. No existing alias changed.
|
|
873
|
+
|
|
874
|
+
### CI
|
|
875
|
+
|
|
876
|
+
- **`actionlint` is now fetched as a SHA256-verified release asset instead of piped through the
|
|
877
|
+
upstream installer script**, with the version and digest pinned as a single source of truth and a
|
|
878
|
+
test that fails the build when any other mention of the version contradicts the pin.
|
|
879
|
+
Supply-chain hardening of a CI-only download; nothing in the published package changes.
|
|
880
|
+
- **The council-review workflow's default bench now names models that actually resolve on a
|
|
881
|
+
runner.** A CI runner has no user config, so a bench entry that only exists as a local alias
|
|
882
|
+
resolved to nothing there — which is why `inkling` joins the shipped alias table above.
|
|
883
|
+
- **A cross-file citation gate** (`scripts/check-citations.js`, wired into pre-commit and CI)
|
|
884
|
+
checks that a `file.js :: symbol` or `file:line` reference in a comment or doc still resolves to
|
|
885
|
+
what it names, so the citation rot this release repeatedly hit is caught at commit time.
|
|
886
|
+
- **The pre-commit gates read the git index, not the working tree**, so a partially-staged commit
|
|
887
|
+
is checked as it will land rather than as it looks on disk.
|
|
888
|
+
|
|
889
|
+
## [4.7.1] - 2026-08-09
|
|
890
|
+
|
|
891
|
+
### Changed
|
|
892
|
+
|
|
893
|
+
- **`continue`, `resume`, and `--retry-failed` now inherit the parent session's/wave's tag,
|
|
894
|
+
instead of dropping it.** `amicus list` now shows a TAG for continuations (they no longer group
|
|
895
|
+
under `(unattributed)` for `--group-by tag`), and `--tag` is now **rejected** on `continue` and
|
|
896
|
+
`resume` — the tag can only come from the parent, it cannot be overridden. This deletes a
|
|
897
|
+
documented limitation, not a bug: `docs/usage.md` carried a paragraph headed "Known limitation: a
|
|
898
|
+
tag is not inherited", and v4.7.0's own release notes called the gap "future work, not
|
|
899
|
+
oversights." That limitation is now gone.
|
|
900
|
+
- **`opencode-ai` and `@opencode-ai/sdk` are pinned to exactly `1.18.15`** (previously `^1.2.20` /
|
|
901
|
+
`^1.1.36`). Dev and CI now run on the same engine version end users resolve, so this is the
|
|
902
|
+
first release whose test suite actually ran against the engine users get. Honest scope: the pin
|
|
903
|
+
makes the *resolved* engine a pure function of the amicus version — it does nothing for a user
|
|
904
|
+
whose installed amicus version hasn't moved, and it does not force any existing npx cache to
|
|
905
|
+
re-resolve (that mechanism was investigated for #133 and found not to exist).
|
|
906
|
+
|
|
907
|
+
### Fixed
|
|
908
|
+
|
|
909
|
+
- **The no-output backstop message now reports what it observed, not what it guessed.** The old
|
|
910
|
+
three-line message asserted "likely a listed-but-not-serving model or a dead endpoint" with no
|
|
911
|
+
evidence behind it; it now names only what the deadline mechanism actually saw, and names
|
|
912
|
+
`AMICUS_NO_OUTPUT_BACKSTOP_MS` as the live governing window only when it is one, calling it out
|
|
913
|
+
as an overridden default otherwise.
|
|
914
|
+
- **The no-output backstop window now doubles on a Stage-1 retry, clamped to the leg timeout, so
|
|
915
|
+
retries can now heal a class of no-output failure that previously required a manual rerun.**
|
|
916
|
+
This is not a fix for the backstop class itself — only Stage-1 bench/critic/lens units retry;
|
|
917
|
+
judges, chair, chair-repair, and debate legs do not, and can still hit the backstop with no
|
|
918
|
+
automatic recovery.
|
|
919
|
+
- **`doctor` now reports engine version skew between installs instead of only grading on
|
|
920
|
+
presence.** A stale npx-cached copy of `opencode-ai` next to a newer global install now surfaces
|
|
921
|
+
as a WARN.
|
|
922
|
+
- **`npm root -g` now resolves on Windows** (it previously threw `ENOENT`/`EINVAL`, making the
|
|
923
|
+
global amicus install invisible to `doctor`). This also un-blinds `doctor --fix`'s donor
|
|
924
|
+
selection, which depends on seeing that global install to pick a healthy engine to copy from.
|
|
925
|
+
- **`npm test` no longer fails intermittently with `ENOENT: … src\__sizecheck_tmp__.js`.**
|
|
926
|
+
`tests/scripts/check-file-sizes.test.js` writes a real `src/__sizecheck_tmp__.js` and unlinks it a
|
|
927
|
+
few milliseconds later. It cannot move that fixture to a tmpdir: `checkAllTracked()` filters its
|
|
928
|
+
input through the anchored `src/**/*.js` include glob and only then resolves against
|
|
929
|
+
`process.cwd()`, so a tmpdir path is dropped before the read and the test would assert on an empty
|
|
930
|
+
violation list. Three suites walk `src/` in parallel jest workers, and a directory listing taken
|
|
931
|
+
while that file existed followed by a read issued after the unlink threw ENOENT — killing
|
|
932
|
+
`no-phantom-dependencies.test.js` during collection ("Test suite failed to run") on roughly 1 full
|
|
933
|
+
run in 2, with `cli-template-args.test.js` and `council/degrade-invariant.test.js` exposed
|
|
934
|
+
identically (both confirmed failing under a reproduction that widens the collision window). All
|
|
935
|
+
three now read through the new `tests/helpers/read-if-present.js`, which skips a file that vanished
|
|
936
|
+
mid-walk and still throws on any non-ENOENT failure, so an unreadable source file stays loud.
|
|
937
|
+
Pre-existing — reproduces at 0fe6128, unrelated to the graphify integration.
|
|
938
|
+
|
|
939
|
+
### Internal
|
|
940
|
+
|
|
941
|
+
- **`finalizeSpendForReopen` extracted to `src/sidecar/reopen-spend.js`**, out of
|
|
942
|
+
`sidecar/continue.js`, to keep that file under the 300-line gate; `resume.js` was already
|
|
943
|
+
reaching across into `continue.js` for it, so the shared home is the honest one.
|
|
944
|
+
- **Deleted three unreachable helpers from `scripts/validate-docs.js`** (dead code with no call
|
|
945
|
+
site remaining after earlier `--check` work landed).
|
|
946
|
+
- **Three doc/marker-freshness gates, all jest-enforced — one genuinely new, two hardened.**
|
|
947
|
+
`tests/docs-command-coverage.test.js` and the anchor-link gate
|
|
948
|
+
(`tests/docs-council-toc-anchors.test.js`) both already existed and already ran in CI; this
|
|
949
|
+
branch hardens them (the former now derives its command list from `bin/amicus.js`'s switch
|
|
950
|
+
instead of hardcoding five entries, the latter generalizes from one file to sixteen). Only the
|
|
951
|
+
CLAUDE.md AUTO-marker/cross-link freshness gate in this changelog entry's own commit is genuinely
|
|
952
|
+
new — `generate-docs --check` had never run in CI before — so a stale `CLAUDE.md` can no longer
|
|
953
|
+
pass CI silently.
|
|
7
954
|
|
|
8
955
|
## [4.7.0] - 2026-08-08
|
|
9
956
|
|