amicus 4.7.0 → 4.8.0

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