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
@@ -0,0 +1,284 @@
1
+ // src/council/ledger-join.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/ledger-join
6
+ * How ONE pair group resolves into ONE ledger row's fields: which of its
7
+ * runStats rows may decide `role`/`conformance` (SI-17's normalise), which
8
+ * street-cred rows are its own (the seat-aware join, T3.3), and — since v4.8
9
+ * SI-18 — which of the alias's raised findings are its own. Split out of
10
+ * ./ledger at v4.8 T3.3, on the same seam T3.0 used for ./ledger-stats: that
11
+ * split took the READ half out, this one takes the JOIN SEMANTICS out, and
12
+ * `buildLedgerRows` keeps only the row assembly. Both dependencies run one way
13
+ * (ledger.js -> ledger-join.js / ledger-stats.js) and neither can cycle.
14
+ *
15
+ * ⚠️ REQUIRE-FREE by design — the ./seats · ./run-stats-entry · ./peer-split
16
+ * precedent. Nothing here reads the filesystem or the config dir.
17
+ * ⚠️ NOT re-exported from ./ledger: none of the four is used outside the row
18
+ * build, so adding them to that module's export list would only risk the
19
+ * AUTO:modules truncation its own comment warns about. Tests import them here.
20
+ *
21
+ * ⚠️ FIVE named mutants guard these, alongside LEDGERALIAS on ledger.js's
22
+ * join key. Each mutation and its MEASURED red set is recorded with the rest:
23
+ * tests/council/street-cred-mutants.js :: CHAIRWINS guards benchLegs, while
24
+ * tests/council/street-cred-mutants.js :: CREDALIAS guards WHETHER the seat
25
+ * lookup can ever win, tests/council/street-cred-mutants.js :: ANYSEATED
26
+ * (v4.8 follow-up) guards HOW COMPLETE a group's seats must be before it does,
27
+ * tests/council/street-cred-mutants.js :: ALIASLASTWINS guards how the lookup
28
+ * combines what it finds, and tests/council/street-cred-mutants.js ::
29
+ * FINDINGALIAS (v4.8 SI-18) guards splitFindingsBySeat the same wholesale way.
30
+ * RE-RUN them, never renumber them.
31
+ */
32
+
33
+ /**
34
+ * v4.8 T3.3 — SI-17's NORMALISE, per owner ruling R4 ("normalise before the
35
+ * ledger join, inside Phase 3 — works on all paths including the two
36
+ * hand-assembled `appendRun` ones a preflight guard cannot reach"). It lives on
37
+ * the WRITE path — here, called from ledger.js :: buildLedgerRows — for exactly
38
+ * that reason: `cli-handlers-council.js` and `mcp-server.js` copy `meta`
39
+ * verbatim out of user JSON, and the documented `amicus council tally` shape
40
+ * PUTS THE CHAIR ON THE BENCH. Both re-read 2026-08-20: docs/council.md's
41
+ * `## Worked example` meta block is `models ["deepseek","gpt"]` with
42
+ * `chair "deepseek"`, and tests/council/fixtures/av-receiver-input.js is
43
+ * `models ["deepseek","gpt","mistral"]` with the same chair. There it is the
44
+ * normal case rather than an edge — though neither of those two carries a
45
+ * `role: 'chair'` runStats row, so neither is what this function changes.
46
+ *
47
+ * A `role: 'chair'` row is the CHAIR SYNTHESIS leg. That is a different
48
+ * contract from a bench review — prose plus a VERDICT line, not findings JSON —
49
+ * so its `conformance` and `role` describe a different job from the seat row it
50
+ * shares an alias and a resolution with. MEASURED at BASE `b341b273` on
51
+ * `--models ds,gpt --chair ds`, both legs resolving to `v/ds`:
52
+ *
53
+ * bench UNSTRUCTURED + chair clean -> role 'chair', conformance 'unstructured'
54
+ * bench clean + chair UNSTRUCTURED -> role 'chair', conformance 'unstructured'
55
+ * the same bench with the chair OFF it -> role 'council', conformance 'clean'
56
+ *
57
+ * The two directions are INDISTINGUISHABLE in the persisted row, and neither
58
+ * agrees with the off-bench control for the same bench leg — in an append-only
59
+ * file that is never migrated. Normalising means the bench leg decides both
60
+ * fields whenever the group holds one, so a seat's ledger identity no longer
61
+ * depends on whether that model also chaired.
62
+ *
63
+ * `wasChair` is NOT normalised away: it is a fact about the run rather than
64
+ * about a contract, it stays any-wins over the WHOLE group (T15), and it is the
65
+ * one thing the chair row legitimately contributes to a bench seat's row.
66
+ * A group holding ONLY chair rows — the split-resolution chair leg of T16, a
67
+ * give-up row — is unchanged: there is no bench leg to prefer, so the chair row
68
+ * still decides both fields and nothing is lost.
69
+ *
70
+ * ⚠️ ON A MIXED GROUP SOMETHING IS LOST, AND IT IS NOT `wasChair`. The chair
71
+ * leg's `conformance` no longer reaches the ledger AT ALL there, so a BROKEN
72
+ * chair synthesis by a model that also sat on the bench becomes invisible to
73
+ * the lifetime histogram; only `wasChair: true` records that it chaired.
74
+ * MEASURED end to end through appendRun -> ledger-stats.js ::
75
+ * deriveReliability, on `--models ds,gpt --chair ds` with a CLEAN bench leg and
76
+ * an UNSTRUCTURED chair leg, both resolving to `v/ds`:
77
+ *
78
+ * b341b273 : conformance { unstructured: 1 }
79
+ * after : conformance { clean: 1 }
80
+ *
81
+ * That is the intended direction — the bench leg's own contract outcome is what
82
+ * a bench seat's row should report, and the old value was the chair's outcome
83
+ * wearing the seat's name — but it is a REAL loss of signal, not a relabelling,
84
+ * and no other surface picks it up. It is disclosed here rather than repaired:
85
+ * recording a chair leg's conformance in its own right would mean giving the
86
+ * chair its own ledger identity, which changes the row SET and is out of this
87
+ * task's scope. FILED for the owner to rule on, not decided here.
88
+ * @param {Array<object>} group one pair group's runStats rows
89
+ * @returns {Array<object>} the rows that may decide `role` and `conformance`
90
+ */
91
+ function benchLegs(group) {
92
+ const bench = group.filter(r => r.role !== 'chair');
93
+ return bench.length ? bench : group;
94
+ }
95
+
96
+ /** Mean of the numeric values of `field` across `rows`; null when none is numeric. */
97
+ function meanCred(rows, field) {
98
+ const nums = rows.map(r => r[field]).filter(v => typeof v === 'number');
99
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
100
+ }
101
+
102
+ /**
103
+ * v4.8 T3.3 — the street-cred join, seat-aware. THE HAZARD THIS EXISTS FOR:
104
+ * `new Map(streetCred.map(s => [s.model, s]))` is last-wins by alias. At BASE
105
+ * `b341b273` that was a genuine no-op — measured on bench `['a','a','b']`, the
106
+ * two twin rows were BYTE-IDENTICAL (3 rows in, `Map` size 2: one dropped, but
107
+ * LOSSLESSLY). The moment `street-cred.js :: rankPositions` is seat-keyed those
108
+ * rows DIVERGE, and the same silent drop starts losing a real seat's numbers
109
+ * into an append-only file. That is why the two halves shipped in ONE commit
110
+ * (controller ruling ledger C-1) and why no intermediate tree may hold one
111
+ * without the other.
112
+ *
113
+ * TWO LOOKUPS, TRIED IN THAT ORDER, because the two sides are indexed by
114
+ * INDEPENDENT producers and either can be seated without the other:
115
+ * 1. the group's own seats, named by `runStats[].seat` (emit-when-DIFFERENT),
116
+ * resolved through `sc` — which holds ONLY the seated street-cred rows —
117
+ * and ONLY when EVERY row in the group resolves this way;
118
+ * 2. failing that (including a PARTIAL resolution), every street-cred row
119
+ * carrying this ALIAS.
120
+ *
121
+ * ⚠️ (1) IS ALL-OR-NOTHING. v4.8 follow-up (2026-08-21), council finding 3 on
122
+ * PR #176, reproduced at BASE `13cd0a2d`: this function used to take lookup
123
+ * (1) the moment ANY of the group's seats resolved through `sc`, discarding
124
+ * the numbers of any OTHER row in the SAME group whose seat did not resolve.
125
+ * A MIXED group — one seated row, one not — then read NARROWER than a group
126
+ * with NO seat information at all, which falls straight to (2)'s alias mean.
127
+ * Partial seat information produced a narrower read than no information,
128
+ * which is backwards — the mirror image of fix round 1's defect just above.
129
+ * MEASURED on a two-row `a` group, seat A resolving to peersOnly 1 and seat B
130
+ * — same alias, unresolvable through `sc` — to peersOnly 5:
131
+ *
132
+ * before : MIXED group (A seated, B not) peersOnly 1 (A alone)
133
+ * NEITHER seated peersOnly 3 (mean of A, B)
134
+ * after : MIXED group (A seated, B not) peersOnly 3 (== neither seated)
135
+ *
136
+ * FIX: lookup (1) now requires EVERY row's seat to resolve in `sc` —
137
+ * `ids.length > 0 && ids.every(id => id && sc.has(id))`. Anything short of
138
+ * that — one row unseated, one row seated to an id `sc` does not hold, or an
139
+ * empty group — falls through to (2), the same alias mean a fully-unseated
140
+ * group already read. ⚠️ `ids.length > 0` is load-bearing on its own:
141
+ * `[].every(...)` is vacuously `true` in JavaScript, so without that guard an
142
+ * EMPTY group would count as "fully identified", resolve through an empty
143
+ * `ids` to an empty seated array, and return `{}` (via the `!rows.length`
144
+ * guard below) instead of falling through to the alias mean the empty-group
145
+ * case already relied on. Killed by the named mutant
146
+ * tests/council/street-cred-mutants.js :: ANYSEATED, which reverts the gate
147
+ * to its pre-fix "any resolves" form and reproduces the MIXED regression
148
+ * above; CREDALIAS (below the function) is the narrower mutant forcing the
149
+ * seat lookup to never win AT ALL, fully-seated groups included.
150
+ *
151
+ * ⚠️ WHY (2) IS A FILTER AND NOT AN ALIAS KEY IN `sc`. Fix round 1, Important 1
152
+ * — this function REGRESSED that quadrant and the regression was measured, not
153
+ * argued. When the street-cred rows are seated and the runStats rows are NOT,
154
+ * an alias-keyed map holds no entry for the alias at all, so the old
155
+ * `sc.get(model) || {}` resolved to `{}` and the row persisted NULL street
156
+ * cred. MEASURED on one hand-assembled record driven through the real
157
+ * `tally()` -> `buildLedgerRows()`, bench `['a','a','b']` with `meta.seats` and
158
+ * no `runStats[].seat`:
159
+ *
160
+ * b341b273 : ledger row a withSelf 2.667 peersOnly 3
161
+ * 46719a7f : ledger row a withSelf null peersOnly null
162
+ *
163
+ * That quadrant is not exotic: mcp-tools.js :: amicus_council_tally declares
164
+ * `meta.seats` while its `runStats` is `z.array(z.record(z.any()))`, so nothing
165
+ * asks that caller for a seat on a runStats row, and cli-handlers-council.js
166
+ * passes user JSON through verbatim. Both then `appendRun` into a file that is
167
+ * never migrated, and ledger-stats.js :: deriveReliability filters
168
+ * `typeof v === 'number'`, so a null drops the run out of
169
+ * `avgStreetCredPeersOnly` — the value run-chair.js :: pickFallbackChair ranks
170
+ * on. This is precisely the hand-assembled path owner ruling R4 puts in scope.
171
+ *
172
+ * ⚠️ THE MEAN IS THE FALLBACK, NOT A LAST-WINS ALIAS KEY, and the two are
173
+ * DISTINGUISHABLE only on input BASE could never produce. At `b341b273`
174
+ * `computeStreetCred` was `models.map(m => f(m, judgePos))` — deterministic in
175
+ * `m` — so two rows sharing an alias were always byte-identical, and a mean
176
+ * across them equals the last-wins value exactly. VERIFIED BY BRUTE FORCE at
177
+ * BASE over 4374 cases (every 3-slot bench over three aliases x two judges x
178
+ * nine order shapes each, strict and tied, under two judge-name pairs), which
179
+ * produced 3402 duplicated-alias groups: ZERO had rows that differ. So the mean
180
+ * is byte-identical to BASE on every shape BASE could produce, and is the honest
181
+ * answer on the two shapes it cannot: seated rows that genuinely diverge, and
182
+ * hand-assembled input that hands us two differing rows under one alias — where
183
+ * last-wins would silently discard one, which is the whole defect class this
184
+ * task exists to close. Pinned by ledger.test.js T12b, "the alias fallback
185
+ * MEANS its rows, it does not take the last one", and killed by the named
186
+ * mutant tests/council/street-cred-mutants.js :: ALIASLASTWINS.
187
+ *
188
+ * ⚠️ A group can cover MORE THAN ONE SEAT — two twins that resolved to the same
189
+ * executable are one (alias, resolvedModel) pair — and a ledger row has ONE
190
+ * street-cred slot. The row SET is not this task's to change (`meta.models`
191
+ * stays the driver, and PR4b's emission order is one row per pair group), so
192
+ * with one slot the choice is between dropping seats and combining them. The
193
+ * mean is the only combination that reads every seat and still returns a lone
194
+ * seat's own number unchanged.
195
+ * ⚠️ It is NOT weight-preserving, and saying otherwise would be the easy wrong
196
+ * claim: ledger-stats.js :: deriveReliability averages ROWS, so N seats folded
197
+ * into one row count once where N separate rows would count N times. That
198
+ * matters only when the aggregate mixes this group with other rows or other
199
+ * runs — and it is still strictly better than today, which reads exactly one of
200
+ * the N and discards the others without saying so.
201
+ * @param {Map<string, object>} sc the SEATED street-cred rows, keyed by seat id
202
+ * @param {Array<object>} group one pair group's runStats rows
203
+ * @param {string} model the block's alias
204
+ * @param {Array<object>} streetCred the record's street-cred rows, for (2)
205
+ */
206
+ function credFor(sc, group, model, streetCred) {
207
+ // Named mutant "ANYSEATED": revert this gate to its pre-fix "any resolves"
208
+ // form —
209
+ // const seated = [...new Set(group.map(r => r.seat).filter(Boolean))]
210
+ // .map(id => sc.get(id)).filter(Boolean);
211
+ // const rows = seated.length ? seated : (streetCred || []).filter(...);
212
+ // tests/council/street-cred-mutants.js :: ANYSEATED.
213
+ const ids = group.map(r => r.seat);
214
+ const fully = ids.length > 0 && ids.every(id => id && sc.has(id));
215
+ // Named mutant "CREDALIAS": force `fully` to never be true, so the seat
216
+ // lookup can never win even on a genuinely fully-seated group —
217
+ // const rows = fully ? [...] : fallback; -> const rows = false ? [...] : fallback;
218
+ // tests/council/street-cred-mutants.js :: CREDALIAS.
219
+ const rows = fully ? [...new Set(ids)].map(id => sc.get(id))
220
+ : (streetCred || []).filter(s => s && s.model === model);
221
+ if (!rows.length) { return {}; }
222
+ return { withSelf: meanCred(rows, 'withSelf'), peersOnly: meanCred(rows, 'peersOnly') };
223
+ }
224
+
225
+ /**
226
+ * v4.8 SI-18 — the FINDINGS attribution, seat-aware. THE HAZARD THIS EXISTS
227
+ * FOR: ledger.js used to compute `findings.filter(f => f.raiser === model)`
228
+ * once per alias and hand the WHOLE result to the block's FIRST pair group
229
+ * (R4b-2's concentration), regardless of how many pair groups PR4b's fan-out
230
+ * gave that alias. On a twin bench whose two seats resolve to DIFFERENT
231
+ * executables — already two separate rows since PR4b — every finding still
232
+ * landed on the first row and the second read 0 and null rates; on a twin
233
+ * bench sharing ONE executable, the single row combined both seats' findings
234
+ * with no way to tell them apart. `findings[].raiserSeat` (tally.js:114-115,
235
+ * emit-when-DIFFERENT from `raiser`) has named the seat that actually raised
236
+ * each finding since v4.8 PR3 Task 5 (anonymize.js :: toGlobalFindings); this
237
+ * join simply never read it. That is the half T3.3's seat-aware `credFor`,
238
+ * just above, did not close.
239
+ *
240
+ * PER-FINDING, NOT PER-GROUP MEAN. Unlike `credFor`'s street-cred numbers, a
241
+ * finding is a discrete item with exactly one true home, not a value to
242
+ * average away. A finding whose `raiserSeat` names a seat exactly one pair
243
+ * group's own runStats rows carry (seat ids are unique within an alias's own
244
+ * table, so at most one group can ever match) is credited to THAT group. The
245
+ * row SET itself never moves — PR4b's (alias, resolvedModel) pairing is
246
+ * unchanged; only which existing row a finding's numbers land on can.
247
+ *
248
+ * THE FALLBACK IS R4b-2, NARROWED, NOT REPLACED. A finding that cannot be
249
+ * resolved to a specific group — `raiserSeat` absent (every pre-seat
250
+ * document, every hand-assembled one), or present but matching no group's own
251
+ * seat (the asymmetric quadrant tally.js's own comment names: both
252
+ * hand-assembled `appendRun` callers can seed `meta.seats` — and so
253
+ * `streetCred` — without ever being asked for a seat on a `runStats` row, so
254
+ * a document can carry `raiserSeat` while no runStats row anywhere carries
255
+ * `.seat`) — still concentrates on the block's FIRST pair group, exactly as
256
+ * R4b-2 always has. So: a document with no seat channel at all leaves every
257
+ * finding unresolved, landing exactly where it always did; a document with a
258
+ * full seat channel splits findings precisely across the rows PR4b's fan-out
259
+ * already produces, and only a genuinely unattributable finding still falls
260
+ * back to the anchor. On the single-row case — twins sharing one executable —
261
+ * every seat's findings resolve to the SAME (only) group, so they combine
262
+ * there exactly as they do today: the row SET, not the attribution rule, is
263
+ * what limits how far apart two twins' numbers can land.
264
+ * @param {Array<Array<object>>} groups this alias's pair groups' runStats
265
+ * rows, in emission order (ledger.js's `groups.map(([, group]) => group)`)
266
+ * @param {Array<object>} raised this alias's raised findings
267
+ * (`findings.filter(f => f.raiser === model)`)
268
+ * @returns {Array<Array<object>>} one findings array per group, same length
269
+ * and order as `groups`; group 0 additionally absorbs every finding no
270
+ * group's own seats could place
271
+ */
272
+ function splitFindingsBySeat(groups, raised) {
273
+ const seatSets = groups.map(group => new Set(group.map(r => r.seat).filter(Boolean)));
274
+ const mine = groups.map(() => []);
275
+ const leftover = [];
276
+ for (const f of raised) {
277
+ const idx = f.raiserSeat ? seatSets.findIndex(s => s.has(f.raiserSeat)) : -1;
278
+ (idx === -1 ? leftover : mine[idx]).push(f);
279
+ }
280
+ if (mine.length) { mine[0] = mine[0].concat(leftover); }
281
+ return mine;
282
+ }
283
+
284
+ module.exports = { benchLegs, credFor, splitFindingsBySeat, meanCred };
@@ -0,0 +1,100 @@
1
+ // src/council/ledger-stats.js
2
+ 'use strict';
3
+ // The ledger READ/aggregate half: readRows, avg, countRuns, deriveReliability,
4
+ // buildStatsDoc, and LEDGER_FILE. Moved verbatim from ledger.js@f207538c:192-274
5
+ // (LEDGER_FILE was ledger.js@f207538c:15) — v4.8 Phase 3 T3.0 size-gate split,
6
+ // zero behavior. REQUIRE-FREE of ./ledger by design: this is the leaf the WRITE
7
+ // half (buildLedgerRows/appendRun, which stay in ledger.js) depends on, so the
8
+ // dependency runs one way (ledger.js -> ledger-stats.js) and cannot cycle.
9
+ // ledger.js re-exports all six, so no import path in the tree moved.
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { getConfigDir } = require('../utils/config');
13
+
14
+ const LEDGER_FILE = 'council-ledger.jsonl';
15
+
16
+ function readRows(dir) {
17
+ const file = path.join(dir, LEDGER_FILE);
18
+ if (!fs.existsSync(file)) { return []; }
19
+ return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
20
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
21
+ }
22
+
23
+ function avg(nums) { return nums.length ? nums.reduce((s, x) => s + x, 0) / nums.length : null; }
24
+
25
+ /**
26
+ * v4.8 PR4b (R4b-1): `runs` counts distinct council RUNS, not ledger rows. One
27
+ * run on `--models a,a` is one appearance, not two — and that was true of every
28
+ * bench where one executable served more than one seat, pre-PR4b rows included
29
+ * (history self-corrects; the append-only file never blends two counting units).
30
+ *
31
+ * ⚠️ Spell the predicate literally: only a NON-EMPTY STRING runId is an
32
+ * identity. `runId: ''` is persistable and reaches disk verbatim on the
33
+ * hand-assembled tally path, and `runId: 0` is the mirror hazard — under a
34
+ * bare `if (r.runId)` or an `'runId' in r` both would silently collapse a
35
+ * whole history into ONE run, permanently, in a file that is never migrated.
36
+ * Anything that is not a non-empty string counts individually.
37
+ */
38
+ function countRuns(rows) {
39
+ const ids = new Set();
40
+ let unkeyed = 0;
41
+ for (const r of rows) {
42
+ if (typeof r.runId === 'string' && r.runId) { ids.add(r.runId); } else { unkeyed += 1; }
43
+ }
44
+ return ids.size + unkeyed;
45
+ }
46
+
47
+ /**
48
+ * Aggregate the ledger per model. peersOnly nulls excluded; lowN flags < 3 runs
49
+ * (v4.8 PR4b R4b-1: `runs` is DISTINCT runIds — see countRuns).
50
+ * v4.7 GOA-7 D10: groups by `row.resolvedModel || row.model` — v2 rows segment
51
+ * by the executable id that actually served; rows without a resolvedModel
52
+ * (pre-v2 history, leg-less rows, hand-assembled tally input) stay alias-keyed
53
+ * with `legacy: true`. `aliases` lists every row-level `model` (alias) observed
54
+ * for the group, most recently observed FIRST — ledger append order is the only
55
+ * recency signal (`date` is day-granular, free-form on the MCP path), so
56
+ * aliases[0] is the launch-preferred name (pickFallbackChair, D11).
57
+ * Version-blind by design: schemaVersion is never read (legacy-read, R2).
58
+ */
59
+ function deriveReliability(opts = {}) {
60
+ const dir = opts.dir || getConfigDir();
61
+ const byKey = new Map();
62
+ for (const row of readRows(dir)) {
63
+ const key = row.resolvedModel || row.model;
64
+ if (!byKey.has(key)) { byKey.set(key, []); }
65
+ byKey.get(key).push(row);
66
+ }
67
+ return [...byKey.entries()].map(([model, rows]) => {
68
+ const peers = rows.map(r => r.streetCredPeersOnly).filter(v => typeof v === 'number');
69
+ const confirms = rows.map(r => r.confirmRate).filter(v => typeof v === 'number');
70
+ const facts = rows.map(r => r.factErrorRate).filter(v => typeof v === 'number');
71
+ const conformance = rows.reduce((acc, r) => { acc[r.conformance] = (acc[r.conformance] || 0) + 1; return acc; }, {});
72
+ const lastSeen = new Map();
73
+ rows.forEach((r, i) => { lastSeen.set(r.model, i); });
74
+ const aliases = [...lastSeen.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
75
+ const runs = countRuns(rows);
76
+ return {
77
+ model, runs, lowN: runs < 3,
78
+ avgStreetCredPeersOnly: avg(peers),
79
+ lifetimeConfirmRate: avg(confirms),
80
+ lifetimeFactErrorRate: avg(facts),
81
+ conformance,
82
+ aliases,
83
+ ...(rows.every(r => !r.resolvedModel) ? { legacy: true } : {}),
84
+ };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * v4.0 §7: wrap the deriveReliability() rows in the council v2 envelope —
90
+ * THE one sanctioned breaking shape change (`council stats --json` used to
91
+ * emit the bare array). Human rendering keeps consuming the bare rows.
92
+ * @param {Array<object>} models deriveReliability() output
93
+ * @returns {{schemaVersion: number, type: 'council-stats', models: Array<object>}}
94
+ */
95
+ function buildStatsDoc(models) {
96
+ const { COUNCIL_SCHEMA_VERSION } = require('./tally');
97
+ return { schemaVersion: COUNCIL_SCHEMA_VERSION, type: 'council-stats', models };
98
+ }
99
+
100
+ module.exports = { LEDGER_FILE, readRows, avg, countRuns, deriveReliability, buildStatsDoc };