amicus 4.7.1 → 4.8.1

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