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/docs/council.md CHANGED
@@ -39,7 +39,7 @@ orchestration recipe. This page is the reference for the artifacts that recipe p
39
39
 
40
40
  ```
41
41
  Stage 1 reviews (models, via `amicus fanout`)
42
- │ each leg saved as review-<model>.md (prose + trailing ```json findings block)
42
+ │ each leg saved as review-<seat>.md (prose + trailing ```json findings block)
43
43
 
44
44
  amicus council validate <leg-file> ← per-leg findings-block check (tri-state exit)
45
45
  │ ok:true → findings[] usable
@@ -211,9 +211,9 @@ Key semantics:
211
211
  ```
212
212
  council-<runId>/
213
213
  briefing-stage1.md # composed seat briefing (user briefing + templates)
214
- review-<model>.md x N # Stage-1 outputs
214
+ review-<seat>.md x N # Stage-1 outputs (one per bench seat)
215
215
  bundle-stage2.md # anonymized judge bundle (identical for all judges)
216
- judge-<model>.md x N # Stage-2 raw outputs
216
+ judge-<seat>.md x N # Stage-2 raw outputs (one per judging seat)
217
217
  chair-packet.md # de-anonymized chair packet (+ verdict-scale addendum)
218
218
  chair-output.md # chair raw output
219
219
  tally-input.json # the assembled five-keys object (auditability)
@@ -224,6 +224,22 @@ council-<runId>/
224
224
  _scratch/ # cwd for judge legs (isolation)
225
225
  ```
226
226
 
227
+ **`<seat>` in those filenames is the seat id, not the model alias.** A seat id *is* its alias
228
+ whenever that alias occupies exactly one bench position — which is every bench with no repeated
229
+ `--models` entry, so these filenames are unchanged there. When the same alias occupies more than
230
+ one seat, the seats are `<alias>#1`, `<alias>#2`, … and the files they write are
231
+ `review-<alias>-1.md`, `review-<alias>-2.md`, and so on. The same rule names the `judge-`,
232
+ `rebuttal-` and `revote-` files below. The Council Workspace reads these names directly: its
233
+ allowlist is built from `run.seats`, so each seat's own file opens under that seat.
234
+
235
+ One case is deliberately left unattributed. When a leg cannot be bound to a seat, the engine still
236
+ writes its output — under the **alias**, because that is all it knows (`review-<alias>.md`). That
237
+ file stays readable, but it is attributed to **no seat**: `run.json` records that *an* orphan
238
+ happened, not *which* seat produced it, and guessing would be exactly the silent mis-attribution
239
+ seat identity exists to prevent. If such a name collides with another seat's own artifact — possible
240
+ when one alias sanitizes onto another seat's filename — neither is attributed and the run-integrity
241
+ banner names both claimants.
242
+
227
243
  Two more files appear when the run was started through **`amicus_council_run`** rather than the
228
244
  CLI, both written by the MCP handler before it spawns the engine:
229
245
 
@@ -276,14 +292,14 @@ one round.
276
292
  `--no-ledger` and writes `tally-provisional.json`. If nothing landed Contested or Disputed,
277
293
  there is nothing to debate — the engine skips straight to the final tally
278
294
  (`debate.outcome: "nothing-to-debate"`).
279
- 2. **Defense.** Every Contested/Disputed finding goes back to its raiser as one concurrent solo
280
- run — `rebuttal-<model>.md` per raiser — asking for exactly one of `DEFEND` / `AMEND` /
281
- `WITHDRAW` per finding. A dead or unparseable defense leg means the original claim stands
282
- undefended.
283
- 3. **Re-vote.** Defended/amended findings go back to the judges who disputed them, as ONE
295
+ 2. **Defense.** Every Contested/Disputed finding goes back to the **seat** that raised it as one
296
+ concurrent solo run — `rebuttal-<seat>.md` per raising seat — asking for exactly one of
297
+ `DEFEND` / `AMEND` / `WITHDRAW` per finding. A dead or unparseable defense leg means the
298
+ original claim stands undefended.
299
+ 3. **Re-vote.** Defended/amended findings go back to the **seats** that disputed them, as ONE
284
300
  shared fanout wave — `revote-bundle.md` (the shared prompt, written to the run dir like
285
- Stage 2's `bundle-stage2.md`) + `revote-<model>.md` per judge. A missing/unparseable re-vote
286
- line leaves that judge's original verdict standing.
301
+ Stage 2's `bundle-stage2.md`) + `revote-<seat>.md` per re-voting seat. A missing/unparseable
302
+ re-vote line leaves that seat's original verdict standing.
287
303
  4. **Final tally.** The engine reassembles the tally input with the defense/re-vote outcomes
288
304
  folded in and re-tallies — this final, post-rebuttal tally is the one that appends to the
289
305
  reliability ledger (a lens run is the only thing that suppresses the append — `council run` has no `--no-ledger` escape hatch; that flag is parsed only by `council tally`). Withdrawn findings stay
@@ -303,7 +319,13 @@ after the re-vote keeps its final tier.
303
319
  finding with what happened in the round and the tier it held before the re-vote.
304
320
  - Extra run-dir artifacts, written only when a defense/re-vote actually ran:
305
321
  `tally-provisional.json`, `revote-bundle.md`, `debate.json` (the round's structured record),
306
- `rebuttal-<model>.md` × (raisers), `revote-<model>.md` × (disputing judges).
322
+ `rebuttal-<seat>.md` × (raising seats), `revote-<seat>.md` × (disputing seats).
323
+ - **Both waves are sized in seats, so a bench that repeats an alias costs more here.** Two seats
324
+ sharing an alias that both raise a contested finding get two defense solos, and two that both
325
+ dispute get two re-vote legs — up to two extra billed legs per duplicated pair per round, plus a
326
+ bounded repair solo for either of them if its output does not parse. `revoteJudges` above counts
327
+ the re-vote legs; nothing in `run.json`'s `debate` object counts the defense solos, though every
328
+ one of them appends its own wave id to `stages[]` under `debate-defense`.
307
329
  - `--claude-review <file>` enters Claude's own review (from a file, no leg launched) as a judged
308
330
  entry; per the reserved-seat rule, it is never asked to defend in the debate round — its
309
331
  Contested/Disputed findings simply stand, the same "originals stand" outcome as a dead defense
@@ -534,8 +556,10 @@ amicus council tally <input.json> [--json] [--no-ledger]
534
556
  ```
535
557
 
536
558
  Reads a **tally-input** JSON file, computes the deterministic tally **record**, prints it (human
537
- summary, or the full record with `--json`), and — unless `--no-ledger` — appends one row per
538
- model to the reliability ledger. Thin CLI wrapper over `tally()` (`src/council/tally.js`).
559
+ summary, or the full record with `--json`), and — unless `--no-ledger` — appends one row per distinct
560
+ (`model`, `resolvedModel`) pair to the reliability ledger (v4.8 see **Ledger append** below;
561
+ that is one row per model on an ordinary bench). Thin CLI wrapper over `tally()`
562
+ (`src/council/tally.js`).
539
563
 
540
564
  ### Tally-input schema
541
565
 
@@ -547,14 +571,15 @@ under "Stage-2 → tally assembly recipe"). It needs **all five top-level keys**
547
571
  | Field | Type | Meaning |
548
572
  |---|---|---|
549
573
  | `meta.runId` | string | Run identifier (conventionally the run-folder stem). |
550
- | `meta.models` | string[] | Every reviewed model id, including `"claude"` when "Claude in the council" is on — this is the street-cred universe. |
574
+ | `meta.models` | string[] | Every reviewed model id, including `"claude"` when "Claude in the council" is on — this is the street-cred universe: `streetCred[]` has exactly one row per `meta.models` entry, always (`streetCred.length === meta.models.length`, holds on any input — **v4.8 follow-up**, closing a case where a `meta.seats` table that disagreed with `meta.models` in count used to drop or invent a row), in `meta.models` order — the k-th occurrence of a repeated alias takes the k-th row, so a non-adjacent repeat (e.g. `["a","b","a"]`) is never grouped by alias. |
551
575
  | `meta.chair` | string | The confirmed chair model id. |
552
576
  | `meta.claudeInCouncil` | boolean | The Stage-0 toggle. |
553
577
  | `meta.runType`, `meta.date` | string (optional) | Free-form labels carried through to `verdict.json`. |
554
- | `findings[]` | array | One entry per finding across all reviews: `{id, raiser, severity}` (`claim` may ride along but isn't required by the tally engine). `id` is the run-global label (e.g. `A1`, `B2`) assigned during Stage-2 assembly, not the reviewer's local integer id. |
555
- | `adjudications[]` | array | One entry per (judge × finding): `{findingId, judge, verdict}`, `verdict {agree, dispute, neutral}`. Include every judge's verdict on every finding, **including the raiser's own adjudication of its own finding** — the engine excludes it automatically when scoring (don't pre-filter it). |
556
- | `rankings[]` | array | One entry per judge: `{judge, order}`. `order` is that judge's `FINAL RANKING:` block translated to model ids, e.g. `["gpt", "deepseek"]` (ties may use a nested array, e.g. `[["gpt","deepseek"], "mistral"]`). |
557
- | `runStats[]` | array | One row per paid launch (v4.7 spec §5 D1/D2 no longer capped at one row per model; see the role roster below): `{model, role, wasChair, conformance, status, durationMs, usage, waveId?, resolvedModel?}`. May be `[]`. Any leg with no run document gets `durationMs: null, usage: null` never invent a value. `waveId` is emit-only-when-set. `resolvedModel?` (v4.7) — the executable id that actually served the row's leg, emit-only-when-set; leg-less rows (the give-up chair row, dead seats with no leg, the claude row) never carry it. `model` stays the council alias. |
578
+ | `meta.seats` | array (optional) | **v4.8** the run's seat table, one `{id, alias, role, lens, position}` entry per **bench** seat in bench order. The engine emits it **only when the bench repeats an alias**: that is the one case where the `alias#N` ids on `findings[].raiserSeat`, `adjudications[].seat` and `runStats[].seat` resolve to nothing else in the document, since `meta.models` is the *alias* list. Bench-only `claude` is never a seat, so never assume `meta.models.length === meta.seats.length`, and never join the two positionally. ⚠️ **Absence never means "the bench had no repeated alias."** Hand-assembled and MCP-assembled tally input reaches `tally()` with no seat machinery behind it at all; absence means only "no seat table available". |
579
+ | `findings[]` | array | One entry per finding across all reviews: `{id, raiser, severity}` (`claim` may ride along but isn't required by the tally engine). `id` is the run-global label (e.g. `A1`, `B2`) assigned during Stage-2 assembly, not the reviewer's local integer id. `raiserSeat?` (**v4.8**) — the raising **seat's** id (`deepseek#1`), emit-only-when-it-differs-from-the-alias, so a bench with no repeated alias never carries it. `raiser` stays the alias in every case. |
580
+ | `adjudications[]` | array | One entry per (judge × finding): `{findingId, judge, verdict}`, `verdict ∈ {agree, dispute, neutral}`. `seat?` (**v4.8**) the judging **seat's** id, on the same emit-when-different terms as `findings[].raiserSeat`; `judge` stays the alias. Include every judge's verdict on every finding, **including the raiser's own adjudication of its own finding** — the engine excludes it automatically when scoring (don't pre-filter it). ⚠️ **v4.8: that exclusion is seat-conditional.** When a vote *and* its finding both carry a seat id, the engine compares **seats** (`v.seat !== f.raiserSeat`), so on a bench that repeats an alias a twin's genuine vote on its twin's finding is now counted instead of discarded. When either side carries no seat id — a legacy document, a hand-assembled one, or a real run whose leg failed to bind to its seat — it falls back to comparing **aliases**, which is the pre-v4.8 behaviour and still drops that twin's vote. Never fill in a seat id you did not observe just to unlock the seat compare. ⚠️ **`""` is not a model id.** The schema accepts an empty string for `raiser` and for `judge`, but the engine cannot identify a vote it has no name for: when a finding's `raiser` is empty or missing, every vote whose `judge` is also empty or missing is excluded from `basis` and counted in `findings[].unattributedPeerDrops`. A **seat id on both sides overrides this** — it is a stronger identity than either name, so a seated vote is scored (or excluded as the raiser's own) regardless of what `raiser` and `judge` say. Send the real alias, or expect the vote not to be scored. |
581
+ | `rankings[]` | array | One entry per judge: `{judge, order}`. `order` is that judge's `FINAL RANKING:` block translated to model ids, e.g. `["gpt", "deepseek"]` (ties may use a nested array, e.g. `[["gpt","deepseek"], "mistral"]`). `seat?` (**v4.8**) — the judge's own seat id, on the same emit-when-different terms as `adjudications[].seat`; `judge` stays the alias. `orderSeats?` (**v4.8**) the seat-valued parallel of `order`, slot for slot (a tied slot is a nested array there too): each slot is a seat id where the ranked model's seat is known, `null` where it is not, and the whole key is emitted only when at least one slot is non-null. **Two consumers read it.** Street cred keys on `orderSeats` when present and falls back to the alias otherwise — the mechanism that lets a twin bench's two street-cred rows diverge instead of collapsing into one. **v4.8** — the **chair packet** is the second: its peer-rankings block zips `orderSeats` onto `order` slot for slot, so the chair reads seat ids where the run knows them and the ranked alias where it does not. A tied slot is zipped element by element, and a `null` slot renders the alias rather than the word `null`. |
582
+ | `runStats[]` | array | One row per paid launch (v4.7 spec §5 D1/D2 — no longer capped at one row per model; see the role roster below): `{model, role, wasChair, conformance, status, durationMs, usage, waveId?, resolvedModel?, seat?}`. `seat?` (**v4.8**) is the row's seat **id**, emit-only-when-it-differs-from-that-seat's-own-alias — so only a bench that repeats an alias carries it. Only the two producers that *have* a seat pass one: the primary reviewing-seat rows and the dead-seat rows. A `judge`, `chair-attempt`, `repair` or `superseded` row never carries it (all four are excluded from the ledger join, so a seat stamp there could never win it), and neither do the off-bench chair rows or the synthetic `claude` row, which have no seat at all. Two seats of one alias that **both** died usually get **two** rows: each carries its own `seat` id where the run bound that seat's leg, and **no** `seat` where it could not — an unidentified dead seat is counted but never named. They still collapse into a **single** row in two cases, both of them seats the run genuinely cannot tell apart: both legs missing a task id, and a run with no seat table behind it — the deliberate floor, since inventing an identity there would be a guess. A retry wave that came back with **fewer legs than it launched** was a third such case and is **no longer**: v4.8 T-A4 made the retry reconcile count a key's SLOTS rather than test its presence, so both twins get a row (measured end to end through `runStage1`: 1 primary dead-seat row before, 2 after, with the superseded rows unchanged at 2 — one of the two rows carries `usage: null`, and **which one is arbitrary**: neither row names a seat, so the alias's billed total is split across its anonymous rows by row ORDER, never by identity). ⚠️ **Corrected in v4.8** — this cell previously claimed the two *always* collapse into one row carrying no `seat`. That was already wrong for **bound** twins the day it was written (the two-row behaviour landed 2026-08-13, this sentence 2026-08-14), and v4.8 closed the unbound half for every retry outcome, the partial return included (that last one in T-A4). May be `[]`. Any leg with no run document gets `durationMs: null, usage: null` — never invent a value. `waveId` is emit-only-when-set. `resolvedModel?` (v4.7) — the executable id that actually served the row's leg, emit-only-when-set; leg-less rows (the give-up chair row, dead seats with no leg, the claude row) never carry it. `model` stays the council alias. ⚠️ **One row shape carries `usage` with NO `waveId`, `resolvedModel` or `durationMs` (v4.8)**: an unidentified dead seat on an alias the bench repeats, where the run holds a billed retry leg it cannot attribute to either twin. The cost is real and is counted in the run total, but every per-seat execution fact is withheld rather than guessed — so do **not** assume `usage` implies `waveId`, and do not treat a null `durationMs` as "this seat cost nothing". |
558
583
 
559
584
  **`runStats[].role` roster (v4.7 row-per-launch).** Every leg the run budget counts gets exactly
560
585
  one row, so a seat that needed a repair or lost a leg to a retry can now show up more than once.
@@ -577,10 +602,15 @@ suffixed label (`rebuttal`/`revote` render unsuffixed); `council tally` has no p
577
602
  at all, only an aggregate. And only `chair-attempt`/`repair`/`superseded` are filtered out of the
578
603
  Workspace seats panel — `rebuttal`/`revote` rows still render there.
579
604
 
580
- `runStats[].waveId` names the exact wave/leg a row was built from, present **iff a real billed
581
- leg backs the row** — e.g. the synthetic `claude` row, a give-up chair's error row, and a
605
+ `runStats[].waveId` names the exact wave/leg a row was built from, present **iff the row can
606
+ name a billed leg as its OWN** — e.g. the synthetic `claude` row, a give-up chair's error row, and a
582
607
  leg-less dead-seat/critic/lens primary error row (the two SL-2 retry note-classes that never
583
- produced a real leg for the seat at all) carry none. It's the join key the leg–row bijection
608
+ produced a real leg for the seat at all) carry none. ⚠️ **Corrected in v4.8** this read *"iff a
609
+ real billed leg backs the row"*, which stopped being true when unidentified dead twins gained the
610
+ row shape described in the `runStats[]` cell above: a real billed leg backs those rows (their
611
+ `usage` is on the record and in the run total) and they still carry no `waveId`, because the leg
612
+ belongs to a seat the row cannot claim to be. **So `waveId` is not a safe filter for "rows carrying
613
+ billed usage" — read `usage` for that.** It's the join key the leg–row bijection
584
614
  invariant suite (`tests/council/run-cost-bijection.test.js`) uses to prove every budget-counted
585
615
  leg lands on exactly one row. `resolvedModel` follows the same emit-only-when-set discipline and
586
616
  the same never-invent rule — it is never derived from the alias.
@@ -601,7 +631,7 @@ groups by `resolvedModel || model` — see the stats section below.
601
631
  "type": "council-tally",
602
632
  "meta": { "...": "echoed from input" },
603
633
  "judged": true,
604
- "streetCred": [ { "model": "gpt", "withSelf": 1, "peersOnly": 1, "perJudgeRank": { "...": "..." } } ],
634
+ "streetCred": [ { "model": "gpt", "withSelf": 1, "peersOnly": 1, "perJudgeRank": { "...": "..." }, "seat": "gpt#1" } ],
605
635
  "findings": [
606
636
  { "id": "A1", "raiser": "deepseek", "severity": "major", "tier": "Confirmed",
607
637
  "basis": { "a": 1, "d": 0, "n": 0 }, "confidence": "thin",
@@ -617,12 +647,16 @@ groups by `resolvedModel || model` — see the stats section below.
617
647
  | `schemaVersion` | Tally-record schema version (currently `2`, council family v2 — see `type` below). This is a *separate* version line from the `--json` **error-envelope** schema version used by `BAD_ARGS` failures (also currently `2`) — the two happen to share a value right now but evolve independently; don't conflate them when scripting against output. |
618
648
  | `type` | Document-type discriminator; always `"council-tally"` (council family v2 envelope). |
619
649
  | `judged` | `true` only when `rankings.length >= 2`. `false` (1 or 0 rankings) means street-cred numbers exist but rest on thin cross-review. |
620
- | `streetCred[].withSelf` | Mean rank position across **all** judges' rankings (lower = better). |
621
- | `streetCred[].peersOnly` | Mean rank position **excluding** the model's own ranking of itself. This is the number used everywhere else (ledger, `stats`, bench recommendations). |
650
+ | `streetCred[].withSelf` | Mean rank position across **all** judges' rankings (lower = better). **v4.8: `streetCred[]` is now one row per SEAT, not one row per alias** — a bench that repeats an alias used to emit two byte-identical rows under that shared alias; each seat now gets its own row, with its own numbers. A unique-alias bench is unaffected: one alias is one seat there, so the row set and every number stay byte-identical to before. |
651
+ | `streetCred[].peersOnly` | Mean rank position excluding this row's own ranking of itself (lower = better). **v4.8: the exclusion is seat-conditional.** When this row and a judge both carry a seat id, the engine compares **seats** — so on a bench that repeats an alias, a twin's OTHER seat is a real peer and its ranking of this seat counts, even though it shares this row's alias. Only when either side lacks a seat id (a unique-alias bench, or a document with no seat channel) does it fall back to comparing **aliases**, which is the pre-v4.8 behaviour and the only case a document without seats can produce. This is the number used everywhere else (ledger, `stats`, bench recommendations). |
652
+ | `streetCred[].seat` | **v4.8**, optional. This row's own seat id, on the same emit-when-**different**-from-the-alias terms as the tally-input schema's `rankings[].seat` above. Absent unless the bench repeated an alias. |
622
653
  | `findings[].tier` | One of `Confirmed \| Contested \| Singleton \| Disputed` — see the cascade below. |
623
- | `findings[].basis` | `{a, d, n}` = peer agree/dispute/neutral counts (raiser's own vote excluded when a raiser is known). |
654
+ | `findings[].basis` | `{a, d, n}` = peer agree/dispute/neutral counts (the raiser's own vote is excluded when a raiser is known). **v4.8: the exclusion is seat-conditional.** When the vote *and* the finding both carry a seat id the engine excludes by **seat**, so a twin's real vote on its twin's finding now counts; otherwise it falls back to excluding by **alias**, exactly as before. Consequence on a bench that repeats an alias: findings move tier in **both** directions — a lone twin corroboration promotes `Singleton → Confirmed` and `thin → solid`, and a twin *dispute* can demote `Confirmed → Contested` or `Contested → Disputed`. ⚠️ **v4.8: the seat comparison decides first, and it no longer needs a known raiser.** When the vote *and* the finding both carry a seat id, that pair settles it however `raiser` reads: same seat means the raiser's own vote (excluded), different seats mean a real peer (counted). Only when the seats cannot decide does the raiser's name matter. ⚠️ **And when the raiser is *not* known, every vote whose `judge` is equally unidentifiable is excluded too.** An empty-string or missing `raiser` is not an identity, so a vote with an empty-string or missing `judge` cannot be told apart from the raiser's own and does not count as peer signal; every **named** judge still counts. Together these close the case where a finding's own raiser voted its finding up — a document with `raiser: ""` and a `judge: ""` vote used to score that vote into `basis`, and so did one whose vote carried the raiser's own seat id. Votes excluded because nobody could attribute them are announced in `unattributedPeerDrops` below; votes the seats attributed are not, because nothing about them is ambiguous. The tier can fall as a result — a finding whose only votes are unidentifiable is `Singleton`, not `Confirmed` or `Disputed`. |
624
655
  | `findings[].confidence` | `"thin"` when `a + d <= 1` (only one peer engaged), else `"solid"`. Thin-confidence findings are the ones Claude may override before Stage 4. |
625
656
  | `findings[].tierOverride` | `null` unless Claude recorded an override; shape `{from, to, reason}`. |
657
+ | `findings[].raiserSeat` | **v4.8**, optional — echoed verbatim from the input finding (see the tally-input schema above). Absent unless the bench repeats an alias. |
658
+ | `findings[].sameModelCorroboration` | **v4.8**, optional, `true` only — a warning stamp: after the seat-aware exclusion above, at least one *agreeing* peer of this finding shares the raiser's **alias**, i.e. the corroboration came from another seat of the same model and is not independent. Emitted only when true (never `false`), so a document without it is byte-identical to a pre-v4.8 one. ⚠️ **Alias-only, and it errs in both directions:** it *misses* `--models gpt-5,openai/gpt-5` (genuinely one model under two aliases — votes carry no `resolvedModel` to compare) and it *fires falsely* on a **split alias**, one alias whose two seats happened to resolve to different executables. The reliability ledger uses a different notion of identity for the same run — it treats `(alias, resolvedModel)` as the key — so the two documents can disagree about what "the same model" means. Treat the stamp as "worth a second look", never as proof. |
659
+ | `findings[].unattributedPeerDrops` | **v4.8**, optional, integer `> 0` only — a count of the votes excluded from `basis` that the engine could not attribute to anyone. **Two shapes produce it.** (1) *Raiser named:* the alias fallback excluded a vote while exactly one side of the pair (the finding's `raiserSeat` or the vote's `seat`) carried a seat id and the other did not — a seat-less vote sharing the raiser's alias cannot be told apart from the raiser's own, so excluding it is the safe call **and** may be discarding a real twin's signal. (2) *Raiser not named:* the finding's `raiser` is empty or missing and so is the vote's `judge`, so the vote may be the unnamed raiser's own. ⚠️ **Neither shape includes a vote the seat ids settled.** When the vote and the finding both carry a seat id the engine knows whose vote it is, so excluding it *attributes* it rather than losing it and nothing is announced — this count is for genuine ambiguity only. Emitted only when `> 0`, so a document with no unattributable drop is byte-identical to a pre-v4.8 one. ⚠️ **On shape (1) this field announces the drop; it does not change it.** That vote was already excluded from `basis` before v4.8 and still is — deliberately, by owner ruling R2. ⚠️ **And shape (1) records a POSSIBLE loss, not an established one.** A seat-less vote sharing the raiser's alias is *either* a real twin's signal being discarded *or* the raiser's own vote being correctly excluded — the shape is defined by the engine not being able to tell, so it cannot also be read as knowing. Not being able to tell is precisely why the drop is **announced** rather than silently taken. SI-22.1 / SI-22.2 track that possibility, which this release does not close; they do not record a loss anyone has established. ⚠️ **On shape (2) the exclusion *is* the change**, and `basis` moves: those votes used to be counted, which let an unnamed raiser corroborate its own finding. It is not yet rendered anywhere. As of v4.8 T2.3 the only **artifacts** it reaches are the tally documents — `tally.json`, and `tally-provisional.json` on a debate run, which is the same `tally()` record written as an audit artifact before any debate leg launches. `verdict.js`'s findings literal is closed and does not copy it, so it never reaches `verdict.json`; `debate.json`'s findings rows are built from a separate closed literal, so it never reaches those either; and no renderer (`council report`, the Workspace matrix, the defense brief) displays it. ⚠️ It **is** present in memory, on the `byRaiser` rows `debate.js`'s `debateTargets` hands to the defense brief — the brief simply never prints it. |
626
660
  | `tierCounts` | Convenience totals across all findings — this is what `renderRecord`'s human-readable summary prints. |
627
661
 
628
662
  **The peers-only tier cascade** (`assignTier(a, d)` — exhaustive over all `(a, d)`):
@@ -638,9 +672,29 @@ A lone corroborating peer (`a=1, d=0`) ranks as **Confirmed (thin)** — it must
638
672
  a lone disputing peer (`a=0, d=1`, which is **Contested (thin)**). A 2-vs-2 split is **Contested**
639
673
  (large-bench tie), not Disputed — `d > a` is required for Disputed, not just `d >= 2`.
640
674
 
641
- **Ledger append.** Unless `--no-ledger`, `tally` writes one row per `meta.models` entry to
642
- `council-ledger.jsonl` (append-only, JSON Lines). Use `--no-ledger` for a re-tally that shouldn't
643
- double-count (e.g. re-running after fixing a malformed input). Two standing uses from the skill's
675
+ **Ledger append.** Unless `--no-ledger`, `tally` writes one row per distinct
676
+ (`model`, `resolvedModel`) pair on the bench to `council-ledger.jsonl` (append-only, JSON Lines).
677
+ That is one row per `meta.models` entry on an ordinary bench, where every alias is unique and
678
+ contributed exactly one joinable `runStats` row — but **not** when one alias was served by one
679
+ executable across more than one seat (v4.8): `--models a,a` whose two seats resolved to the same
680
+ executable writes **one** row, not two, and a chair that is also a bench seat writes one row when
681
+ its chair leg and seat leg resolved to the **same** executable and **two** when they resolved to
682
+ different ones. An alias whose seats resolved *differently* writes one row per executable, so no leg
683
+ is erased. Two *distinct* aliases that share one resolution still write one row **each** — the
684
+ collapse is per alias; it is `council stats` that aggregates them into one executable-keyed group.
685
+ Use `--no-ledger` for a re-tally that shouldn't
686
+ double-count (e.g. re-running after fixing a malformed input): a re-tally appends a second full set
687
+ of rows, which **doubles the conformance histogram** in `council stats` (a tally — nothing divides
688
+ it). It does **not** move the lifetime averages: a duplicated set of rows has the same mean as the
689
+ original. Since v4.8 it no longer doubles `runs`/`low-N` either — those count distinct `meta.runId`
690
+ values, and a re-tally of the same input carries the same one — so a harness that writes a
691
+ **constant** `runId` across genuinely different runs will pin that group at `runs: 1` forever.
692
+ ⚠️ That holds only when `meta.runId` is a **non-empty string**. `council-tally.schema.json` declares
693
+ it as a bare `string`, so `"runId": ""` is valid input, and an empty string is not an identity —
694
+ each such row counts individually, so re-tallying *that* file still inflates `runs` and `low-N`.
695
+ Measured on a one-model bench, three tallies of the same input: `runs` 1 → 2 → 3, with `low-N`
696
+ clearing on the third. A numeric `runId` behaves identically; a real string stays pinned at 1.
697
+ Two standing uses from the skill's
644
698
  optional elements (v2.2.0): **debate mode** tallies provisionally with `--no-ledger` after Stage 2
645
699
  and records only the final post-rebuttal tally, and **expert-lens runs** always pass `--no-ledger`
646
700
  (lens reviews aren't comparable to standard reviews, so they must not feed `stats`). This is
@@ -668,8 +722,14 @@ For each finding, `buildVerdict` looks up the matching decision by `id` and fold
668
722
  `applied` (default `false`), `duplicateOf` (default `null`), and `tierOverride` (decision's
669
723
  override wins over the tally record's, if both are present — the effective `tier` becomes
670
724
  `tierOverride.to` when an override exists). Everything else (`basis`, `confidence`,
671
- `adjudications`, `streetCred`, `runStats`, `tierCounts`) passes through from the tally record
672
- unchanged.
725
+ `adjudications`, `raiserSeat`, `sameModelCorroboration`, `streetCred`, `runStats`, `tierCounts`)
726
+ passes through from the tally record unchanged, and `meta.seats` is carried across as the
727
+ top-level `seats`.
728
+
729
+ ⚠️ **`buildVerdict` is a closed projection, not a copy.** Both of its literals name every key they
730
+ emit — the top level renames `meta.models` to `council`, and each finding is rebuilt from a fixed
731
+ field list — so a key added to the tally record does **not** reach `verdict.json` until it is
732
+ named here. That is why the v4.8 keys below each needed their own line.
673
733
 
674
734
  **Output schema** (`verdict.json`, schema v2 — independent of the tally record's own
675
735
  `schemaVersion`):
@@ -688,7 +748,7 @@ unchanged.
688
748
  "duplicateOf": null, "adjudications": [ "..." ],
689
749
  "decision": "accepted", "applied": true }
690
750
  ],
691
- "streetCred": [ { "model": "gpt", "withSelf": 1, "peersOnly": 1 } ],
751
+ "streetCred": [ { "model": "gpt", "withSelf": 1, "peersOnly": 1, "seat": "gpt#1" } ],
692
752
  "runStats": [ "..." ],
693
753
  "tierCounts": { "Confirmed": 1, "Contested": 1, "Singleton": 1, "Disputed": 0 }
694
754
  }
@@ -698,6 +758,9 @@ unchanged.
698
758
  - `schemaVersion` — verdict-document schema version (currently `2`).
699
759
  - `type` — document-type discriminator; always `"council-verdict"` (council family v2 envelope).
700
760
  - `overallVerdict` — the chair's verdict-scale outcome: one of `"Ship it"`, `"Fix these first"`, `"Fundamental rethink"`, or `null` when no chair verdict was produced (populated by the headless engine during Stage 3; `null` for a plain `council verdict` merge without engine integration).
761
+ - `seats` — **v4.8**, optional. The tally record's `meta.seats` (same `{id, alias, role, lens, position}` shape), promoted to the top level next to `seatLoss`. Present only when the tally record carried one, i.e. only when the bench repeated an alias. It is what makes the `alias#N` ids on `findings[].raiserSeat`, `adjudications[].seat` and `runStats[].seat` resolvable from the verdict **alone** — before v4.8 the verdict named seats it could not resolve. `council report` reads it to give each seat its own adjudication-matrix column; when it is absent, or is not an array of objects each carrying a string `id`, the **adjudication matrix** falls back to alias space whole and renders exactly as it did before v4.8. ⚠️ **That fallback is the matrix's alone — it is not a whole-document guarantee.** The street-cred table beside it labels each row from `streetCred[].seat` whenever the row carries one, a predicate independent of this key, so a verdict with seated `streetCred[]` rows and no usable `seats` renders seat ids in the street-cred table and aliases in the matrix (measured on an absent `seats`, a non-array `seats`, and an array-of-strings `seats`). In-process both fields come from the same twin bench and travel together; the split is reachable on a hand-assembled or externally-supplied record, which `buildVerdict`'s own docblock names. A verdict written before v4.8 carries no `streetCred[].seat` at all and is unaffected.
762
+ - `findings[].raiserSeat` — **v4.8**, optional. The raising seat's id, carried through from the tally record; absent unless the bench repeated an alias. `findings[].raiser` stays the alias.
763
+ - `findings[].sameModelCorroboration` — **v4.8**, optional, `true` only. Carried through from the tally record; see the tally-record notes above for the stamp's meaning **and for the two directions in which it is wrong** (it misses one model behind two aliases, and it fires falsely on one alias behind two executables).
701
764
  - All other keys (`runId`, `council`, `findings`, `streetCred`, `runStats`, `tierCounts`) are passed through unchanged from the tally record.
702
765
 
703
766
  **Write path:** always atomic — a `<out>.tmp-<pid>` file is written first, then renamed over the
@@ -735,7 +798,8 @@ amicus council report <verdict.json> [--wave <wave.json>] [--md|--html]
735
798
  Pure renderer — reads `verdict.json` (+ optional `wave.json`, used only to source the wave's total
736
799
  cost) and produces **one self-contained string**: Markdown (default) or a self-contained HTML page
737
800
  (`--html`). No scoring, no anonymization, no synthesis — those already happened upstream. Thin CLI
738
- wrapper over `buildReport()` (`src/council/report.js` / `report-html.js`).
801
+ wrapper over `buildReport()` (`src/council/report.js`, which builds the neutral model and
802
+ dispatches to `report-md.js` / `report-html.js` — the two renderers that own the string formats).
739
803
 
740
804
  **What it renders**, in this order: a header (run type, id, date, chair, council members), a
741
805
  verdict-summary tier-count table, the **adjudication matrix** (finding × judge, `✓`/`✗`/`–` with
@@ -743,6 +807,84 @@ verdict-summary tier-count table, the **adjudication matrix** (finding × judge,
743
807
  tier** (Disputed first), and a **cost table** (per-model status/duration/cost + wave total,
744
808
  sourced from `runStats[].usage`).
745
809
 
810
+ ⚠️ **v4.8 — on a bench that repeats an alias, the matrix is keyed by SEAT.** When `verdict.json`
811
+ carries a `seats` table the columns are titled `deepseek#1` / `deepseek#2` rather than two
812
+ identical `deepseek` headers, the Raiser cell names the raising **seat**, and the `*` marks that
813
+ seat's column only — so exactly one of two same-alias columns carries it. This is a bug fix as
814
+ much as a rename: the old alias key was **last-wins**, so the second seat's vote overwrote the
815
+ first's and a finding whose `basis` was `a0/d1` could render as two agreements, both starred. Now
816
+ the rendered row and the finding's `basis` agree. **Benches with no repeated alias are
817
+ byte-identical to v4.7** — every seat id there *is* its alias, so nothing in the document differs
818
+ and nothing in the render does either. A verdict with no `seats` table renders its **matrix** in
819
+ alias space exactly as it always has — including anything written before v4.8, which carries no
820
+ seat fields at all. ⚠️ **The street-cred table below the matrix is not covered by that sentence.** Its row labels
821
+ come from `streetCred[].seat`, never from `seats`, so a hand-assembled verdict carrying seated
822
+ street-cred rows without a usable `seats` table shows `deepseek#1`/`deepseek#2` there while the
823
+ matrix stays in alias space. See the `seats` key note in the verdict-document schema above.
824
+
825
+ ⚠️ **v4.8 — the `UNATTRIBUTED` column.** A vote the matrix cannot attribute to a column is no
826
+ longer dropped from the render. The vote→column join **refuses** a key that identifies nothing — an
827
+ empty string, a missing or non-string `judge`, or a seat id or alias that names no column on the
828
+ bench — and folds every such vote into one extra column, headed `UNATTRIBUTED` and placed last
829
+ among the judge columns. This closes the gap this section used to disclose: a judge whose Stage-2
830
+ leg never bound to its seat emits no `adjudications[].seat`, so in seat space its vote keys to a
831
+ bare alias no column reads. Before v4.8 that vote counted in `basis` and rendered nowhere; now it
832
+ counts in `basis` **and** renders.
833
+
834
+ - **`basis` does not move.** This is a rendering change only — such a vote was always counted and
835
+ still is. It is the same property the seat re-key above exists for: what the row shows and what
836
+ `basis` says now agree.
837
+ - **The column appears only when a vote actually folds.** A document in which every vote is
838
+ attributable renders exactly as it did before — no extra column, and never an empty one.
839
+ - ⚠️ **Read the header as “no column on this bench”, not “nobody knows who voted”.** The rule the
840
+ renderer applies is about the **column**, not the voter: a vote lands here when its key names no
841
+ column on *this document's* bench. Usually that also means the voter is unidentifiable — but not
842
+ always. A vote whose `judge` names a model the report deliberately keeps off the bench folds here
843
+ too, with its `judge` field intact in the document. The column says *this vote had nowhere to go*.
844
+ - **Every folded vote on one finding shares one cell, last-wins.** One column is the deliberate
845
+ design — it records a fact about the document, not one per voter — so two folded votes on the same
846
+ finding collapse to the later one's verdict. What tells them apart is a seat id, and supplying one
847
+ is a producer-side fix, not a rendering one.
848
+ - **The Council Workspace matrix applies the same refusal**, over the `tally.json` roster described
849
+ in the paragraph below. The two are deliberately separate implementations rather than a shared
850
+ module, and they are held in agreement by an exhaustive cross-product test rather than by
851
+ construction. ⚠️ **One known exception, disclosed rather than fixed:** on a
852
+ `--claude-review` run the report filters the reserved `claude` seat off its bench while the
853
+ Workspace keeps it, so a hand-authored `judge: "claude"` vote folds to `UNATTRIBUTED` in the
854
+ report and lands in the `claude` column in the Workspace. No engine run emits such a vote. The
855
+ two rosters are built from different sources, and reconciling them is deliberately out of scope
856
+ here.
857
+ - ⚠️ **One residual, disclosed rather than fixed:** if a bench seat is *literally* named
858
+ `UNATTRIBUTED`, no extra column is added and folded votes land in that seat's own column. Nothing
859
+ reserves the name, so this is reachable only by naming a seat that way on purpose.
860
+ - This is **not** the same thing as `findings[].unattributedPeerDrops` in the tally-record schema
861
+ above. That field counts votes the *peer filter* excluded from `basis` on the raiser side, and
862
+ ruling R2 deliberately leaves those excluded; this column renders a vote that
863
+ **is** in `basis` but had no column to land in. Different mechanism, different document, opposite
864
+ effect on `basis`.
865
+
866
+ In the Council Workspace the same matrix is built from `tally.json` (via `tally.meta.seats`) and
867
+ behaves identically, with one deliberate difference: it keeps rendering the blank `claude` column
868
+ that the report filters out. **Blind mode never renders a seat id** — a seat id contains its
869
+ alias, so both twins collapse to `Review A` there, exactly as before v4.8. Its legend is worded
870
+ `* raiser` where the report's reads `` `*` raiser's own vote ``; the two say the same thing, and
871
+ both now refer to the raiser's seat. The report's legend gains a **second** line the Workspace
872
+ matrix does not carry — `` `†` `` marks a finding corroborated only by another seat running the
873
+ same model — so from v4.8.0 the two legends are no longer interchangeable. That line, and the `†`
874
+ itself, appear only on a run that actually raised such a finding, which is a twin bench only.
875
+
876
+ ⚠️ **v4.8 — a SECOND report/Workspace divergence, in the street-cred table, filed not fixed.** The
877
+ sentence above is scoped to the **matrix**, and stays true. The street-cred table is a different
878
+ story: both renderers (`report-md.js`, `report-html.js`) label each row `seat || model`, so a twin
879
+ bench reads `gemini#1` / `gemini#2`, while the Workspace's street-cred table still labels from the
880
+ model alias
881
+ and reads `gemini` twice with different numbers under one identical name. **On a bench that repeats
882
+ an alias the two street-cred tables now disagree**; on any bench with no repeated alias they are
883
+ identical, because every seat id there *is* its alias. The report side is the corrected one. The
884
+ Workspace side is not a one-liner — its blind mode resolves the label through `opts.labelOf(...)`,
885
+ which would have to accept a seat id first — and is filed in `BACKLOG.md` for v4.9 with that
886
+ signature change as its gate.
887
+
746
888
  This is the same renderer the `second-opinion` skill calls in Stage 5 to produce `report.html`.
747
889
  **`report.md` and this renderer's output are two different files** — `report.md` is Claude-authored
748
890
  prose that includes a copy of this renderer's Markdown as one section, not this renderer's own
@@ -955,6 +1097,11 @@ _Legend: ✓ agree · ✗ dispute · – neutral · `*` raiser's own vote_
955
1097
  | **Wave total** | | | $0.0900 |
956
1098
  ```
957
1099
 
1100
+ This bench's two aliases are distinct, so its matrix and legend are byte-for-byte what every
1101
+ pre-v4.8 run produced. On a bench that **repeats** an alias the columns split by seat
1102
+ (`deepseek#1` / `deepseek#2`) and the legend's `*` marks the raiser's **seat** — see "What it
1103
+ renders" above.
1104
+
958
1105
  **6. Stats** — after this run's `tally` call appended to the ledger:
959
1106
 
960
1107
  ```bash
@@ -62,7 +62,7 @@ No plans index is generated **today**. `buildPlansIndex()` in `scripts/generate-
62
62
  never fires. Note that guard is a runtime `fs.existsSync` check, not a disabled feature: creating
63
63
  `docs/plans/` would silently reactivate it, emitting a bare list of `- [name](path)` links without
64
64
  the per-plan first heading or date the old text here promised. Plans actually live in
65
- `docs/superpowers/plans/`, uncataloged.
65
+ `docs/superpowers/plans/`, which are working documents pruned at each release cut — specs in `docs/superpowers/specs/` are the permanent record.
66
66
 
67
67
  ## Commands
68
68
 
@@ -58,3 +58,5 @@ Run top-to-bottom before `npm version`:
58
58
  5. Bump `.claude-plugin/plugin.json` `version` to match `package.json` (no script syncs it). Also bump `server.json` — both `.version` and `.packages[0].version` — to the same value (no script syncs this either). Also bump the `status --json` **example blocks** in `README.md` and `docs/usage.md` (`"version": "x.y.z"`) — they are drift-test-pinned to `package.json` by `tests/docs-quick-sync.test.js` and have now missed the cut twice (v2.1.0 and v2.2.0).
59
59
  6. Update `CHANGELOG.md` (move Unreleased → the new version).
60
60
  7. `npm version <x.y.z> --no-git-tag-version` + plugin.json lockstep + server.json lockstep (`.version` and `.packages[0].version`), single `chore(release): vX.Y.Z` commit, then push main + tag (publish.yml does the rest — see the canonical ritual in Phase 2 of the 2026-07-01 review-execution plan). If you forget the server.json bump, `tests/scripts/package-manifest.test.js` fails the suite (`server.json versions stay in lockstep with package.json`) — that's your safety net, but don't rely on it; do the bump.
61
+
62
+ - [ ] **Prune the rev's plan docs.** Delete `docs/superpowers/plans/*` belonging to this release in the release-cut commit. Specs in `docs/superpowers/specs/` are permanent and stay. Plans are working documents — they are committed on-branch so they cannot be silently rewritten mid-build, and removed at the cut so `main` never accumulates prescriptions that are false the moment they ship. Git history remains the audit trail. `npx jest tests/docs-plan-refs.test.js` fails if anything still cites a specific plan.
@@ -233,15 +233,15 @@ For full headless configuration, see [docs/configuration.md](./configuration.md)
233
233
 
234
234
  ## Headless Leg Fails with `NO_OUTPUT_BACKSTOP`
235
235
 
236
- **Symptom:** A headless leg (`amicus start --no-ui`, or one leg of a `fanout`/council run) fails with an error starting `NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in Ns — the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)`. You may instead see `...in Ns — a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default` — this covers two different cases with the same wording: a Stage-1 retry (its window is the resolved `AMICUS_NO_OUTPUT_BACKSTOP_MS` value **doubled**, so a 120 s default reads `240s` on the retry) and the `amicus models --check --live` probe (a fixed, non-tunable `30s` — see below). Both are "caller-set" in the sense that the specific window wasn't read live from the env var at that call, but only the retry case is actually governed by it.
236
+ **Symptom:** A headless leg (`amicus start --no-ui`, or one leg of a `fanout`/council run) fails with an error starting `NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in Ns — the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)`. You may instead see `...in Ns — a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default` — this covers two different cases with the same wording: a Stage-1 retry (its window is the resolved `AMICUS_NO_OUTPUT_BACKSTOP_MS` value **doubled**, so a 300 s default reads `600s` on the retry) and the `amicus models --check --live` probe (a fixed, non-tunable `30s` — see below). Both are "caller-set" in the sense that the specific window wasn't read live from the env var at that call, but only the retry case is actually governed by it.
237
237
 
238
- **Cause:** The mechanism only knows that the deadline passed with no substantive activity — no output, reasoning, or tool calls — for the backstop window (120 s by default for an ordinary leg). It does **not** know *why*. Do not read this as "the endpoint is dead" or "the model isn't being served" — the message deliberately stops short of that claim, because at one of its two firing sites the backstop can win the race before the prompt send even resolves, so acceptance itself was never observed. Treat `NO_OUTPUT_BACKSTOP` as "silence past the deadline," not a diagnosis.
238
+ **Cause:** The mechanism only knows that the deadline passed with no substantive activity — no output, reasoning, or tool calls — for the backstop window (300 s by default for an ordinary leg). It does **not** know *why*. Do not read this as "the endpoint is dead" or "the model isn't being served" — the message deliberately stops short of that claim, because at one of its two firing sites the backstop can win the race before the prompt send even resolves, so acceptance itself was never observed. Treat `NO_OUTPUT_BACKSTOP` as "silence past the deadline," not a diagnosis.
239
239
 
240
240
  **Lesson from #133:** an earlier version of this message *did* guess a cause ("likely a listed-but-not-serving model or a dead endpoint"), and that guess sent 30 minutes of debugging at model ids and API keys on a real incident. The actual cause was an OpenCode engine version skew between the npx-cached copy and the global install — sitting the whole time in `~/.local/share/opencode/log/opencode.log`. **Before chasing model/endpoint theories, check that log for the session's real error.**
241
241
 
242
242
  **Confirm:** `amicus models --check` — audits your configured aliases against the live catalog and flags drift/staleness. A model that still resolves locally but has quietly been retired upstream is one possible explanation, but a static audit only compares against the catalog's *listing* — it can't tell you whether a still-listed model actually answers. For that, run `amicus models --check --live` on demand: it sends one tiny real request to every stored alias and reports `SERVED` / `SILENT` / `ERROR` per alias, where `SILENT` (`accepted-but-silent`) is this exact `NO_OUTPUT_BACKSTOP` failure, caught deliberately instead of by accident. It spends real money (one tiny leg per stored alias) — see [docs/usage.md § `amicus models`](./usage.md#amicus-models--the-model-catalog) for cost, scope, and exit-code details.
243
243
 
244
- **Fix:** Check the `~/.local/share/opencode/log/opencode.log` for the session's real error first (see #133 above). Then check the alias's target (`amicus models --search <term>` to find the current id, then re-point the alias) — a stale alias is a common case, but not the only one. If the failing leg was a Stage-1 retry (the window read `240s`, or generally double your configured/default value), `AMICUS_NO_OUTPUT_BACKSTOP_MS` **is** the lever — raising it raises both the first attempt's window and the retry's doubled one, unless your `--timeout` is low enough that the doubled window is clamped to the leg timeout. On an ordinary (non-retry) leg, raise it only if a model legitimately needs more than 120 s to produce its first token. The live probe's fixed 30 s window is the one case the env var genuinely cannot touch. See [docs/configuration.md § Headless Poller Tuning](./configuration.md#headless-poller-tuning).
244
+ **Fix:** Check the `~/.local/share/opencode/log/opencode.log` for the session's real error first (see #133 above). Then check the alias's target (`amicus models --search <term>` to find the current id, then re-point the alias) — a stale alias is a common case, but not the only one. If the failing leg was a Stage-1 retry (the window read `600s`, or generally double your configured/default value), `AMICUS_NO_OUTPUT_BACKSTOP_MS` **is** the lever — raising it raises both the first attempt's window and the retry's doubled one, unless your `--timeout` is low enough that the doubled window is clamped to the leg timeout. On an ordinary (non-retry) leg, raise it only if a model legitimately needs more than 300 s to produce its first token. The live probe's fixed 30 s window is the one case the env var genuinely cannot touch. See [docs/configuration.md § Headless Poller Tuning](./configuration.md#headless-poller-tuning).
245
245
 
246
246
  ---
247
247
 
package/docs/usage.md CHANGED
@@ -403,7 +403,7 @@ Each stored alias resolves to one of three outcomes:
403
403
  | Outcome | Example line | Meaning |
404
404
  |---------|--------------|---------|
405
405
  | `SERVED` | `SERVED: gemini -> openrouter/google/gemini-3.6-flash ($0.0004)` | The model answered; cost shown in parens. |
406
- | `SILENT` (`accepted-but-silent`) | `SILENT: probetest -> anthropic/claude-opus-4-8 — NO_OUTPUT_BACKSTOP: … (accepted but not serving)` | The endpoint accepted the request but produced nothing for the probe's 30 s backstop window (shorter than the ordinary 120 s default, and not tunable) the exact "listed but not actually serving" failure this check exists to catch. |
406
+ | `SILENT` (`accepted-but-silent`) | `SILENT: probetest -> anthropic/claude-opus-4-8 — NO_OUTPUT_BACKSTOP: … (accepted but not serving)` | Nothing arrived at all within the probe's 30 s backstop window shorter than the ordinary 300 s leg default, and not tunable. ⚠️ `accepted-but-silent` is the CLASSIFICATION's name, not a proven fact about the endpoint: a fired backstop shows only that no output, reasoning or tool call arrived in the window, which a stalled gateway or a dropped connection produces just as readily as a model that accepted and then said nothing. Either way it is the "listed but not actually serving" failure this check exists to catch. |
407
407
  | `ERROR` | `ERROR: gpt -> openai/gpt-5.6-terra — 402 Payment Required` | Routing, auth, or provider failure; the raw error is printed. |
408
408
 
409
409
  **Exit code.** The probe's non-served count folds into the same exit code as the static audit — `max(existing exit, min(nonServedCount, 100))` — so a single `SILENT` or `ERROR` fails the check even when every alias is otherwise catalog-fresh. No stored aliases prints `Live probe: no stored aliases to probe` and never affects the exit code. `--json` adds `probe` (the per-alias array) and `probeCount` (its length) to the `alias-audit` document — both additive, `[]`/`0` when `--live` wasn't passed.
@@ -518,7 +518,7 @@ $ amicus status demo123 --json
518
518
  "taskId": "demo123",
519
519
  "status": "complete",
520
520
  "elapsed": "5m 0s",
521
- "version": "4.7.1",
521
+ "version": "4.8.1",
522
522
  "model": "google/gemini-2.5-flash",
523
523
  "phase": "terminal"
524
524
  }
@@ -80,10 +80,26 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
80
80
  // Task 8: apply a per-provider default picker choice. Read-modify-write,
81
81
  // no-clobber -- applyProviderDefault only ever writes aliases[vendor] and
82
82
  // seeds config.default when absent (see provider-default-picker.js).
83
- ipcMain.handle('sidecar:set-provider-default', (_event, provider, chosenId) => {
83
+ // Fetches the catalog before applying (issue 195): applyProviderDefault
84
+ // uses directFormIfProven (model-canonicalization.js) to decide whether to
85
+ // strip an OpenRouter prefix off chosenId, and needs the catalog to do it.
86
+ ipcMain.handle('sidecar:set-provider-default', async (_event, provider, chosenId) => {
87
+ let catalog = [];
88
+ try {
89
+ const { getCatalog } = require('../src/utils/model-catalog');
90
+ catalog = await getCatalog();
91
+ } catch (err) {
92
+ // Best-effort only -- a fetch failure leaves `catalog` empty, which
93
+ // directFormIfProven (F1, council review of PR 198) reads as NO
94
+ // evidence, never as license to strip: chosenId is persisted exactly
95
+ // as given, not re-derived. Applying an already-made picker choice
96
+ // must never abort on a catalog hiccup, and must never fabricate an
97
+ // id on one either -- that was the exact bug issue 195 fixed.
98
+ logger.error('set-provider-default catalog fetch error', { error: err.message });
99
+ }
84
100
  try {
85
101
  const { applyProviderDefault } = require('../src/utils/provider-default-picker');
86
- return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
102
+ return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
87
103
  } catch (err) {
88
104
  logger.error('set-provider-default handler error', { error: err.message });
89
105
  return { success: false, error: err.message };
package/electron/main.js CHANGED
@@ -327,11 +327,24 @@ function createAmicusWindow() {
327
327
  async function createSetupWindow() {
328
328
  // Lazy-load setup UI to avoid loading it for sidecar mode
329
329
  const { buildSetupHTML } = require('./setup-ui');
330
- const { resolveQuickPicks } = require('../src/utils/quick-picks');
330
+ const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
331
331
  let quickPicks;
332
+ const shortlists = {};
332
333
  try {
333
334
  const catalog = await require('../src/utils/model-catalog').getCatalog();
334
335
  quickPicks = resolveQuickPicks(catalog);
336
+
337
+ // issue 138: one vendor shortlist per family card, resolved server-side from
338
+ // the same catalog the quick picks came from (no extra IPC round-trip).
339
+ const { buildModelShortlist } = require('../src/utils/model-shortlist');
340
+ for (const p of quickPicks) {
341
+ try {
342
+ shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
343
+ catalog,
344
+ recommendedId: toStorableRoute(p),
345
+ });
346
+ } catch (_e) { /* a shortlist failure must never block the wizard */ }
347
+ }
335
348
  } catch (_err) {
336
349
  quickPicks = undefined; // buildSetupHTML falls back to pinned
337
350
  }
@@ -348,7 +361,7 @@ async function createSetupWindow() {
348
361
  }
349
362
  });
350
363
 
351
- const html = buildSetupHTML({ client: CLIENT, quickPicks });
364
+ const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
352
365
  mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
353
366
  mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
354
367
 
@@ -499,6 +512,36 @@ registerSetupHandlers(() => mainWindow);
499
512
  function createSettingsChildWindow() {
500
513
  const { buildSetupHTML } = require('./setup-ui');
501
514
 
515
+ // issue 138: mirror createSetupWindow's catalog resolution so Step 2 shows
516
+ // live per-model options here too, instead of falling back to the pinned
517
+ // `[offline list]` badge. This window must stay synchronous (opening
518
+ // Settings must never trigger a network fetch), so read the on-disk cache
519
+ // directly with readCache() rather than the async fetch-and-refresh helper
520
+ // createSetupWindow awaits. A missing or corrupt cache reads back as null
521
+ // and degrades to the same pinned fallback buildSetupHTML already applies
522
+ // when no quickPicks are given.
523
+ const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
524
+ const { readCache } = require('../src/utils/model-catalog');
525
+ let quickPicks;
526
+ const shortlists = {};
527
+ try {
528
+ const cacheDoc = readCache();
529
+ const catalog = cacheDoc ? cacheDoc.models : [];
530
+ quickPicks = resolveQuickPicks(catalog);
531
+
532
+ const { buildModelShortlist } = require('../src/utils/model-shortlist');
533
+ for (const p of quickPicks) {
534
+ try {
535
+ shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
536
+ catalog,
537
+ recommendedId: toStorableRoute(p),
538
+ });
539
+ } catch (_e) { /* a shortlist failure must never block the wizard */ }
540
+ }
541
+ } catch (_err) {
542
+ quickPicks = undefined; // buildSetupHTML falls back to pinned
543
+ }
544
+
502
545
  const settingsWin = new BrowserWindow({
503
546
  width: 560, height: 680,
504
547
  parent: mainWindow, modal: false,
@@ -512,7 +555,7 @@ function createSettingsChildWindow() {
512
555
  }
513
556
  });
514
557
 
515
- const html = buildSetupHTML({ client: CLIENT });
558
+ const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
516
559
  settingsWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
517
560
  settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
518
561
  }
@@ -5,7 +5,7 @@
5
5
  * delete, and add functionality for the setup wizard Step 3.
6
6
  */
7
7
 
8
- /** Grouping metadata for the 19 default aliases */
8
+ /** Grouping metadata for the 21 default aliases */
9
9
  const ALIAS_GROUPS = [
10
10
  { name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
11
11
  { name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
@@ -13,7 +13,7 @@ const ALIAS_GROUPS = [
13
13
  { name: 'DeepSeek', keys: ['deepseek'] },
14
14
  { name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
15
15
  { name: 'Mistral', keys: ['mistral'] },
16
- { name: 'Other', keys: ['glm', 'minimax', 'grok', 'kimi', 'seed'] },
16
+ { name: 'Other', keys: ['glm', 'minimax', 'grok', 'kimi', 'seed', 'inkling'] },
17
17
  ];
18
18
 
19
19
  /**