amicus 4.7.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +948 -1
  3. package/README.md +3 -2
  4. package/docs/CITATIONS.md +114 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +181 -34
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +6 -4
  13. package/docs/usage.md +15 -11
  14. package/electron/setup-ui-aliases.js +2 -2
  15. package/electron/workspace-ui/index.html +9 -0
  16. package/electron/workspace-ui/live-dead-seats.js +228 -0
  17. package/electron/workspace-ui/live-model.js +10 -236
  18. package/electron/workspace-ui/live-seats.js +126 -0
  19. package/electron/workspace-ui/workspace-app.js +6 -41
  20. package/electron/workspace-ui/workspace-banners.js +95 -0
  21. package/electron/workspace-ui/workspace-lazy.js +55 -12
  22. package/electron/workspace-ui/workspace-matrix.js +2 -2
  23. package/electron/workspace-ui/workspace-panels.js +42 -10
  24. package/electron/workspace-ui/workspace-render.js +2 -2
  25. package/electron/workspace-ui/workspace-seats.js +101 -17
  26. package/package.json +6 -3
  27. package/schemas/council-run-live.schema.json +1 -0
  28. package/schemas/council-run.schema.json +19 -0
  29. package/schemas/council-tally.schema.json +34 -2
  30. package/schemas/council-verdict.schema.json +15 -0
  31. package/scripts/postinstall.js +6 -3
  32. package/scripts/setup-hooks.js +49 -3
  33. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  34. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  35. package/skills/second-opinion/SKILL.md +25 -11
  36. package/skills/sidecar/SKILL.md +3 -1
  37. package/src/cli-handlers-doctor.js +7 -0
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/peer-split.js +196 -0
  47. package/src/council/report-html.js +12 -5
  48. package/src/council/report-md.js +146 -0
  49. package/src/council/report.js +188 -112
  50. package/src/council/run-assemble.js +100 -79
  51. package/src/council/run-chair.js +17 -1
  52. package/src/council/run-debate-revote.js +268 -0
  53. package/src/council/run-debate.js +92 -102
  54. package/src/council/run-finish.js +70 -0
  55. package/src/council/run-launch.js +56 -18
  56. package/src/council/run-retry-group.js +266 -0
  57. package/src/council/run-retry-keys.js +74 -0
  58. package/src/council/run-retry-launch.js +55 -0
  59. package/src/council/run-retry-notes.js +65 -13
  60. package/src/council/run-retry.js +179 -162
  61. package/src/council/run-stage1-launch.js +27 -10
  62. package/src/council/run-stage1-rows.js +220 -0
  63. package/src/council/run-stage1-superseded.js +156 -0
  64. package/src/council/run-stage2.js +65 -5
  65. package/src/council/run-stages.js +72 -69
  66. package/src/council/run-state.js +1 -1
  67. package/src/council/run-stats-entry.js +71 -0
  68. package/src/council/run-verdict-files.js +52 -0
  69. package/src/council/run.js +42 -52
  70. package/src/council/seats.js +262 -0
  71. package/src/council/stage1-bind.js +142 -0
  72. package/src/council/street-cred.js +258 -0
  73. package/src/council/tally.js +100 -49
  74. package/src/council/verdict.js +49 -1
  75. package/src/headless.js +83 -6
  76. package/src/mcp-tools.js +71 -1
  77. package/src/observe/council-legs.js +7 -1
  78. package/src/observe/live-doc.js +3 -3
  79. package/src/sidecar/continue.js +8 -23
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-retry.js +15 -3
  82. package/src/sidecar/fanout-wave-io.js +26 -1
  83. package/src/sidecar/fanout.js +4 -10
  84. package/src/sidecar/leg-ids.js +19 -0
  85. package/src/sidecar/models-probe.js +7 -4
  86. package/src/sidecar/reopen-spend.js +32 -0
  87. package/src/sidecar/resume.js +1 -1
  88. package/src/sidecar/setup.js +13 -2
  89. package/src/utils/config.js +109 -11
  90. package/src/utils/curated-models.js +17 -2
  91. package/src/utils/degrade.js +5 -0
  92. package/src/utils/doctor-engine-check.js +32 -0
  93. package/src/utils/engine-install-scan.js +98 -15
  94. package/src/utils/engine-repair.js +96 -2
  95. package/src/utils/no-output-backstop.js +1 -1
  96. package/src/utils/remediation-hints.js +42 -0
  97. package/src/utils/session-index-prune.js +297 -0
  98. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  99. package/src/workspace/artifact-guard.js +8 -114
  100. package/src/workspace/artifact-names.js +222 -0
  101. package/src/workspace/fold-format.js +9 -6
  102. package/src/workspace/live-normalize.js +6 -2
  103. package/src/workspace/matrix-model.js +141 -19
  104. package/src/workspace/run-detail.js +27 -1
  105. package/src/workspace/seat-space.js +143 -0
@@ -3,6 +3,11 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getConfigDir } = require('../utils/config');
6
+ // The read/aggregate half lives in ./ledger-stats (v4.8 Phase 3 T3.0 size-gate split).
7
+ // readRows/avg/countRuns/deriveReliability/buildStatsDoc/LEDGER_FILE are re-exported
8
+ // below — existing consumers keep importing them from here.
9
+ const { LEDGER_FILE, readRows, avg, countRuns, deriveReliability,
10
+ buildStatsDoc } = require('./ledger-stats');
6
11
 
7
12
  // v4.7 GOA-7 D9: v2 rows may carry `resolvedModel` (the executable id that
8
13
  // served, copied from the joined runStats row, emit-only-when-set). Absent
@@ -12,7 +17,6 @@ const { getConfigDir } = require('../utils/config');
12
17
  // unknowable. Legacy-READ only: readers never inspect schemaVersion, rows are
13
18
  // never migrated.
14
19
  const LEDGER_SCHEMA_VERSION = 2;
15
- const LEDGER_FILE = 'council-ledger.jsonl';
16
20
 
17
21
  // v4.7 D4/E1/E2/E6 (Task-7, task-6/task-7 adjudications): fail-closed
18
22
  // ALLOWLIST of runStats roles the ledger join (below) may consume as a
@@ -20,7 +24,7 @@ const LEDGER_FILE = 'council-ledger.jsonl';
20
24
  // runs, and the av-receiver golden fixture — errata E2, must stay green).
21
25
  // 'redteam' is the second-opinion skill's documented primary-seat role
22
26
  // (skills/second-opinion/MANUAL-ORCHESTRATION.md:147; red-team runs record
23
- // to the ledger per COUNCIL-DESIGN.md:266 — errata E6, task-7 review: without
27
+ // to the ledger per COUNCIL-DESIGN.md:268 — errata E6, task-7 review: without
24
28
  // it a red-team row's role/wasChair/conformance never join, silently
25
29
  // fabricating conformance:'clean' via the `|| 'clean'` fallback below).
26
30
  // 'judge' stays excluded (#83's overwrite-guard: judges ARE bench models, and
@@ -58,34 +62,169 @@ function countSeverity(findings) {
58
62
  return c;
59
63
  }
60
64
 
61
- /** One model-level row per council model. Rates are over RAW raised findings. */
65
+ // v4.8 PR4b (R4b-4): a LOCAL COPY of run-assemble.js's CONFORMANCE_RANK +
66
+ // worseConformance. Deliberately copied, not imported: this module requires
67
+ // fs/path/utils-config directly, plus its own two size-gate extractions —
68
+ // ./ledger-stats and ./ledger-join (v4.8 T3.0/T3.3) — and neither deepens the
69
+ // graph: ./ledger-join is require-free and ./ledger-stats requires only the
70
+ // same three, plus a LAZY require('./tally') inside buildStatsDoc that never
71
+ // runs at module load. run-assemble pulls findings → anonymize →
72
+ // seats, atomic-write, and — since v4.8 PR4c moved writeVerdictFiles out —
73
+ // run-verdict-files → verdict → report. The graph is one hop longer than the
74
+ // comment PR4b wrote, not one hop shorter. The duplication is paid for
75
+ // by a drift guard (tests/council/ledger.test.js T13a) that asserts pairwise
76
+ // agreement with the exported original — including an UNKNOWN value, which is
77
+ // where the two spellings can silently diverge.
78
+ const CONFORMANCE_RANK = { clean: 0, repaired: 1, unstructured: 2 };
79
+ /** Worst-wins merge; returns its FIRST argument on a rank tie (mirrors worseConformance). */
80
+ function mergeConformance(a, b) {
81
+ return (CONFORMANCE_RANK[a] || 0) >= (CONFORMANCE_RANK[b] || 0) ? a : b;
82
+ }
83
+
84
+ // v4.8 T3.3/SI-18: the JOIN SEMANTICS — SI-17 normalise (benchLegs), the
85
+ // seat-aware street-cred join (credFor), and the seat-aware findings split
86
+ // (splitFindingsBySeat) — live in ./ledger-join, the same one-directional
87
+ // split T3.0 used for ./ledger-stats. Read their docblocks there before
88
+ // changing any call site below.
89
+ const { benchLegs, credFor, splitFindingsBySeat } = require('./ledger-join');
90
+
91
+ /**
92
+ * One row per distinct (model, resolvedModel) pair on the bench. Rates are over
93
+ * RAW raised findings.
94
+ *
95
+ * v4.8 PR4b (spec §4.7/§4.9): the runStats join is a FAN-OUT, not a last-wins
96
+ * Map. `alias → resolvedKey → row[]`, so a bench where one executable served
97
+ * more than one seat (a twin `--models a,a`, two aliases sharing a resolution,
98
+ * a chair that is also a bench seat) no longer erases the losing rows, emits
99
+ * byte-identical duplicates, or double-weights deriveReliability's averages.
100
+ *
101
+ * EMISSION ORDER: one block per DISTINCT alias, blocks ordered ascending by
102
+ * that alias's LAST index in meta.models (ties impossible — lastIndexOf is
103
+ * unique per alias); within a block, pair groups in first-observed runStats
104
+ * order. ⚠️ `lastIndexOf`, not `indexOf`: deriveReliability builds `aliases[]`
105
+ * most-recently-seen-first, and pickFallbackChair LAUNCHES `aliases[0]`, so
106
+ * first-occurrence anchoring (what a naive `new Map(model+'\0'+resolved)`
107
+ * gives for free) promotes the executable-id-shaped name over the short alias
108
+ * on `--models gpt-5,openai/gpt-5,gpt-5` — the form run-chair.js:48-52 argues
109
+ * against. On a bench where no alias repeats AND no alias has more than one
110
+ * joinable runStats row, lastIndexOf === indexOf and the row set and its order
111
+ * are unchanged from pre-PR4b.
112
+ *
113
+ * ⚠️ `meta.models` stays the row driver. Two of the three appendRun call sites
114
+ * feed hand-assembled input (cli-handlers-council.js, mcp-server.js) where
115
+ * runStats may be empty; a runStats-driven loop would emit zero rows there.
116
+ * The dedup of meta.models is UNCONDITIONAL — `['a','a']` with `runStats: []`
117
+ * is one row, not two.
118
+ */
62
119
  function buildLedgerRows(record) {
63
120
  const { meta, findings, streetCred, runStats, judged } = record;
64
- const sc = new Map(streetCred.map(s => [s.model, s]));
65
- // The join below is keyed by MODEL only allowlisted roles (joinsLedger,
66
- // above) may win it, so a non-primary row-per-launch row can never
67
- // silently overwrite a model's real bench (seat) row.
68
- const rs = new Map(runStats.filter(r => joinsLedger(r.role))
69
- .map(r => [r.model, r]));
70
- return meta.models.map(model => {
121
+ // The SEATED street-cred rows only, keyed by seat id. ⚠️ Not `s.seat ||
122
+ // s.model`: an alias key here cannot serve a row whose alias has ONLY seated
123
+ // street cred, which is the fix-round-1 regression credFor's second lookup
124
+ // filters the array by alias instead. The hazard, the emit rule and both
125
+ // lookups are written out at ledger-join.js :: credFor. Named mutant
126
+ // tests/council/street-cred-mutants.js :: LEDGERALIAS.
127
+ const sc = new Map(streetCred.filter(s => s && s.seat).map(s => [s.seat, s]));
128
+ // Only allowlisted roles (joinsLedger, above) may join, so a non-primary
129
+ // row-per-launch row can never contribute a model's role/conformance.
130
+ const byAlias = new Map();
131
+ for (const r of runStats) {
132
+ if (!joinsLedger(r.role)) { continue; }
133
+ if (!byAlias.has(r.model)) { byAlias.set(r.model, new Map()); }
134
+ const pairs = byAlias.get(r.model);
135
+ const key = r.resolvedModel || '';
136
+ if (!pairs.has(key)) { pairs.set(key, []); }
137
+ pairs.get(key).push(r);
138
+ }
139
+ const aliases = [...new Set(meta.models)]
140
+ .sort((a, b) => meta.models.lastIndexOf(a) - meta.models.lastIndexOf(b));
141
+ const rows = [];
142
+ for (const model of aliases) {
71
143
  const raised = findings.filter(f => f.raiser === model);
72
- const s = sc.get(model) || {};
73
- const r = rs.get(model) || {};
74
- const denom = raised.length;
75
- return {
76
- schemaVersion: LEDGER_SCHEMA_VERSION,
77
- runId: meta.runId, date: meta.date, runType: meta.runType, model,
78
- role: r.role || 'council', wasChair: !!r.wasChair, judged: judged === true,
79
- streetCredWithSelf: judged ? (s.withSelf ?? null) : null,
80
- streetCredPeersOnly: judged ? (s.peersOnly ?? null) : null,
81
- findingsRaised: denom,
82
- bySeverity: countSeverity(raised),
83
- confirmRate: judged && denom ? raised.filter(f => f.tier === 'Confirmed').length / denom : null,
84
- factErrorRate: judged && denom ? raised.filter(f => f.tier === 'Disputed').length / denom : null,
85
- conformance: r.conformance || 'clean',
86
- ...(r.resolvedModel ? { resolvedModel: r.resolvedModel } : {}),
87
- };
88
- });
144
+ const pairs = byAlias.get(model);
145
+ // An alias with NO joinable runStats row yields exactly ONE row with an
146
+ // empty group — the shape the founding ledger commit (c073995e) wrote as
147
+ // `rs.get(model) || {}`; `rs` itself is gone, replaced by `byAlias`/`pairs`
148
+ // during the R4b-2 pair-group split, but the empty-group fallback is not.
149
+ const groups = pairs ? [...pairs.entries()] : [['', []]];
150
+ // v4.8 SI-18: findings now split by SEAT across this block's pair groups —
151
+ // ledger-join.js :: splitFindingsBySeat. R4b-2's concentration ("within a
152
+ // block exactly ONE row the FIRST pair group — carries the findings
153
+ // statistics") is now the FALLBACK for whatever that split cannot resolve
154
+ // to a specific group, not the rule for every finding: a finding whose
155
+ // raiserSeat names a seat one of THIS alias's own runStats rows carries is
156
+ // credited to that seat's group; everything else every pre-seat
157
+ // document, every hand-assembled one, and the asymmetric quadrant
158
+ // tally.js documents (`meta.seats` declared while runStats carries no seat
159
+ // at all) — still concentrates on the block's FIRST pair group, exactly as
160
+ // before. This comment used to forecast that findings were alias-attributed
161
+ // "until PR4c"; that forecast EXPIRED UNFULFILLED, and the comment's own
162
+ // claim that this join "still has no seat to split on" went unfixed until
163
+ // SI-18 measured it false: tally.js:114-115 has emitted `raiserSeat` since
164
+ // v4.8 PR3 Task 5, and this join simply never read it. Splitting on the raw
165
+ // executable alone would still fabricate a per-executable confirmRate —
166
+ // this instead reads the seat id itself, the signal that actually names
167
+ // which occurrence raised a finding. The row SET does not move — PR4b's
168
+ // (alias, resolvedModel) pairing is unchanged, only which pair group a
169
+ // finding's numbers land on. Street cred deliberately does NOT concentrate
170
+ // (§0) and is untouched by this change (ledger-join.js :: credFor). Named
171
+ // mutant tests/council/street-cred-mutants.js :: FINDINGALIAS.
172
+ const mineByGroup = splitFindingsBySeat(groups.map(([, group]) => group), raised);
173
+ groups.forEach(([resolvedKey, group], i) => {
174
+ // The other rows' null rates still fall out of the `judged && denom`
175
+ // guards at denom 0, exactly as before.
176
+ const mine = mineByGroup[i];
177
+ const denom = mine.length;
178
+ // SI-17's normalise: a chair-synthesis row never decides a bench leg's
179
+ // role or conformance (ledger-join.js :: benchLegs). `group` is unchanged, so
180
+ // `wasChair` below still reads every row.
181
+ const legs = benchLegs(group);
182
+ const last = legs[legs.length - 1] || {};
183
+ const s = credFor(sc, group, model, streetCred);
184
+ rows.push({
185
+ schemaVersion: LEDGER_SCHEMA_VERSION,
186
+ runId: meta.runId, date: meta.date, runType: meta.runType, model,
187
+ // `role`: last row of the PAIR GROUP wins, chair rows excluded while a
188
+ // bench leg is present. No role ordering exists anywhere in src/, so
189
+ // with no principled merge this stays closest to today's last-wins.
190
+ // Lens twins are genuinely undecidable.
191
+ role: last.role || 'council',
192
+ // `wasChair`: any-wins over the WHOLE group. A boolean fact has no
193
+ // last-wins reading, and this is the one field a chair row contributes
194
+ // to a bench seat's row.
195
+ wasChair: group.some(r => !!r.wasChair),
196
+ judged: judged === true,
197
+ streetCredWithSelf: judged ? (s.withSelf ?? null) : null,
198
+ streetCredPeersOnly: judged ? (s.peersOnly ?? null) : null,
199
+ findingsRaised: denom,
200
+ bySeverity: countSeverity(mine),
201
+ confirmRate: judged && denom ? mine.filter(f => f.tier === 'Confirmed').length / denom : null,
202
+ factErrorRate: judged && denom ? mine.filter(f => f.tier === 'Disputed').length / denom : null,
203
+ // ⚠️ SEED THE FOLD FROM THE GROUP'S FIRST ROW, never from 'clean':
204
+ // mergeConformance returns its first argument on a rank tie and an
205
+ // unknown value ranks 0, so a 'clean' seed would rewrite an unknown
206
+ // conformance to 'clean' on a SINGLE-row group — i.e. on an ordinary
207
+ // unique-alias bench, where today emits it verbatim. T13b is the pin.
208
+ // The seed means group[0] is folded twice (once as seed, once as the
209
+ // first element); that is deliberate and a no-op, because
210
+ // mergeConformance(x, x) === x. Do NOT "simplify" it away.
211
+ // ⚠️ Consequence, inherited from worseConformance's own tie rule and
212
+ // NOT introduced here: an unknown value survives only in position 0.
213
+ // ['weird','clean'] folds to 'weird', ['clean','weird'] to 'clean',
214
+ // because unknown and 'clean' both rank 0 and the accumulator wins the
215
+ // tie. T13c pins it so nobody "fixes" it into a divergence.
216
+ // ⚠️ The fold runs over `legs`, not `group` — SI-17's normalise. On
217
+ // every group without a chair row the two arrays ARE the same array's
218
+ // contents, so T13b/T13c and the seed rule above are untouched.
219
+ conformance: legs.length
220
+ ? legs.reduce((acc, r) => mergeConformance(acc, r.conformance || 'clean'),
221
+ legs[0].conformance || 'clean')
222
+ : 'clean',
223
+ ...(resolvedKey ? { resolvedModel: resolvedKey } : {}),
224
+ });
225
+ });
226
+ }
227
+ return rows;
89
228
  }
90
229
 
91
230
  function appendRun(record, opts = {}) {
@@ -97,64 +236,14 @@ function appendRun(record, opts = {}) {
97
236
  return rows;
98
237
  }
99
238
 
100
- function readRows(dir) {
101
- const file = path.join(dir, LEDGER_FILE);
102
- if (!fs.existsSync(file)) { return []; }
103
- return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
104
- .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
105
- }
106
-
107
- function avg(nums) { return nums.length ? nums.reduce((s, x) => s + x, 0) / nums.length : null; }
108
-
109
- /**
110
- * Aggregate the ledger per model. peersOnly nulls excluded; lowN flags < 3 runs.
111
- * v4.7 GOA-7 D10: groups by `row.resolvedModel || row.model` — v2 rows segment
112
- * by the executable id that actually served; rows without a resolvedModel
113
- * (pre-v2 history, leg-less rows, hand-assembled tally input) stay alias-keyed
114
- * with `legacy: true`. `aliases` lists every row-level `model` (alias) observed
115
- * for the group, most recently observed FIRST — ledger append order is the only
116
- * recency signal (`date` is day-granular, free-form on the MCP path), so
117
- * aliases[0] is the launch-preferred name (pickFallbackChair, D11).
118
- * Version-blind by design: schemaVersion is never read (legacy-read, R2).
119
- */
120
- function deriveReliability(opts = {}) {
121
- const dir = opts.dir || getConfigDir();
122
- const byKey = new Map();
123
- for (const row of readRows(dir)) {
124
- const key = row.resolvedModel || row.model;
125
- if (!byKey.has(key)) { byKey.set(key, []); }
126
- byKey.get(key).push(row);
127
- }
128
- return [...byKey.entries()].map(([model, rows]) => {
129
- const peers = rows.map(r => r.streetCredPeersOnly).filter(v => typeof v === 'number');
130
- const confirms = rows.map(r => r.confirmRate).filter(v => typeof v === 'number');
131
- const facts = rows.map(r => r.factErrorRate).filter(v => typeof v === 'number');
132
- const conformance = rows.reduce((acc, r) => { acc[r.conformance] = (acc[r.conformance] || 0) + 1; return acc; }, {});
133
- const lastSeen = new Map();
134
- rows.forEach((r, i) => { lastSeen.set(r.model, i); });
135
- const aliases = [...lastSeen.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
136
- return {
137
- model, runs: rows.length, lowN: rows.length < 3,
138
- avgStreetCredPeersOnly: avg(peers),
139
- lifetimeConfirmRate: avg(confirms),
140
- lifetimeFactErrorRate: avg(facts),
141
- conformance,
142
- aliases,
143
- ...(rows.every(r => !r.resolvedModel) ? { legacy: true } : {}),
144
- };
145
- });
146
- }
147
-
148
- /**
149
- * v4.0 §7: wrap the deriveReliability() rows in the council v2 envelope —
150
- * THE one sanctioned breaking shape change (`council stats --json` used to
151
- * emit the bare array). Human rendering keeps consuming the bare rows.
152
- * @param {Array<object>} models deriveReliability() output
153
- * @returns {{schemaVersion: number, type: 'council-stats', models: Array<object>}}
154
- */
155
- function buildStatsDoc(models) {
156
- const { COUNCIL_SCHEMA_VERSION } = require('./tally');
157
- return { schemaVersion: COUNCIL_SCHEMA_VERSION, type: 'council-stats', models };
158
- }
159
-
160
- module.exports = { buildLedgerRows, appendRun, deriveReliability, buildStatsDoc, LEDGER_FILE, LEDGER_SCHEMA_VERSION };
239
+ // ⚠️ CLAUDE.md's AUTO:modules marker truncates at five exports — append new
240
+ // ones at the END so the generated table stays stable.
241
+ module.exports = {
242
+ buildLedgerRows, appendRun, deriveReliability, buildStatsDoc, LEDGER_FILE,
243
+ LEDGER_SCHEMA_VERSION,
244
+ // Both exported for the drift guard against run-assemble.js's sibling copy.
245
+ mergeConformance, CONFORMANCE_RANK,
246
+ // Re-exported from ./ledger-stats (v4.8 Phase 3 T3.0 split); no import path
247
+ // in the tree moved.
248
+ readRows, avg, countRuns,
249
+ };
@@ -0,0 +1,196 @@
1
+ // src/council/peer-split.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/peer-split
6
+ * The peer-split predicate: which adjudications on a finding count as PEER
7
+ * signal, excluding the raiser's own vote — and, since v4.8 T-B4, excluding
8
+ * every vote that CANNOT BE TOLD APART from the raiser's own. Extracted
9
+ * verbatim from tally.js@115bc861:93-112 (v4.8 Phase 2 T-B1, zero behavior
10
+ * change); T-B4 is the first change to what it computes. BOTH consumers call
11
+ * it: tally.js since T-B1, debate.js since
12
+ * T-B2 — which is this module's whole reason to exist, because the tally the
13
+ * chair reads and the defense brief the raiser reads must be ONE computation,
14
+ * not two spellings of it. The exact tally.js comment split this extraction
15
+ * made is recorded in task-1-report.md
16
+ * (.superpowers/sdd/2026-08-19-v48-t23-peer-split/, gitignored). No mutation
17
+ * record lives there: every named mutant on this module is in the tree and
18
+ * tracked, in tests/council/peer-split-mutants.js :: SPLITDROP.
19
+ *
20
+ * ⚠️ REQUIRE-FREE by design, like ./seats and ./run-stats-entry: debate.js's
21
+ * own docblock declares it DI-free, so any module it requires must itself
22
+ * pull in nothing else. Do not add a `require` call here — a pin
23
+ * (tests/council/peer-split.test.js) strips this file's comments and scans the
24
+ * executable text that is left, so it fires on a real call and no longer on the
25
+ * same sequence written inside a comment. ⚠️ v4.8 T-B5 narrowed THIS pin only;
26
+ * run-stats-entry.js's twin still scans raw text, so the two are no longer
27
+ * symmetric — do not assume one from the other.
28
+ */
29
+
30
+ /**
31
+ * Peer votes for one finding: every adjudication in `votes` except the ones
32
+ * that are, or might be, the raiser's own. One principle — attribute when you
33
+ * can, mark only when you cannot (ruling R2) — in three branches:
34
+ * P0 the vote AND the finding both carry a seat id ⇒ the SEATS decide, for
35
+ * ANY raiser. Equal ⇒ the raiser's own vote, excluded and NOT marked
36
+ * (we know what it is). Different ⇒ a real peer, counted.
37
+ * P3 else, a NAMED raiser ⇒ exclude by alias, exactly as before v4.8 T-B4.
38
+ * P1/P2 else (falsy raiser) ⇒ keep every NAMED judge, because a named judge
39
+ * is provably not the unnamed raiser; drop every falsy one, because it
40
+ * may be the raiser's own, and count it in `unattributedPeerDrops`.
41
+ * @param {{raiser?: string, raiserSeat?: string}} f - the finding
42
+ * @param {Array<{judge?: string, seat?: string, verdict?: string}>} votes - its adjudications
43
+ * @returns {Array} the peer-filtered votes
44
+ */
45
+ function peersOf(f, votes) {
46
+ // Only exclude the raiser's own vote BY NAME when a raiser is known; the
47
+ // raiser is populated by the orchestrator (not the reviewer JSON), so an
48
+ // unset raiser must not silently drop a real peer vote (L8).
49
+ //
50
+ // v4.8 PR4c §3.3 (#137): compare SEATS when both sides carry one, aliases
51
+ // otherwise. On a twin bench the alias compare drops a twin's real vote —
52
+ // measured on ['deepseek','deepseek','gpt'], one corroborating peer reported
53
+ // as `Singleton {a:0,d:0}`; on three deepseeks the whole cross-review was
54
+ // discarded. The guard is NOT the naive `v.seat !== f.raiserSeat`: both
55
+ // producers are `X && X.id !== alias` over independent bind operations
56
+ // (anonymize.js's raiserSeat over Stage 1, run-assemble.js's adjudication
57
+ // seat over Stage 2), each `|| null` by design, so exactly one side carrying
58
+ // a seat id is ENGINE-reachable in both directions whenever bindSeats
59
+ // orphans a twin leg — and there the naive form reads `undefined !== 'x#1'`
60
+ // and silently promotes a Singleton to Confirmed on the raiser's own vote.
61
+ // debate.js :: debateTargets CALLS this function as of v4.8 T-B2, so the
62
+ // defense brief's peer split can no longer disagree with the tally.
63
+ //
64
+ // v4.8 T-B4 (council C1 on PR #174, chair verdict "fix these first") changed
65
+ // the FALSY-raiser arm, which until then handed `votes` back whole. `''` and
66
+ // `undefined` are not identities (ruling R2), so a vote whose `judge` is also
67
+ // falsy may be the unnamed raiser's own — and it was being counted as peer
68
+ // signal. Measured on the council's fixture: `raiser:''` with votes
69
+ // `['' agree, 'gpt' agree]` gave `basis {a:2}` Confirmed SOLID where the
70
+ // named-raiser control gives `{a:1}` thin, with nothing emitted to say so.
71
+ // Reachable in production: mcp-tools.js:416 declares a bare `z.string()` for
72
+ // `raiser`, so '' validates, and that path reaches the append-only ledger.
73
+ // The defect is PRE-EXISTING — measured at base e7cf54b0, the tally read
74
+ // `{a:2}` there too — so T-B2 propagated it to the brief rather than causing
75
+ // it. R2 governs the fix: mark explicitly, attribute nothing.
76
+ //
77
+ // The falsy-raiser branch keeps every NAMED judge, which is what L8's
78
+ // rationale actually protects — a named judge is provably not the unnamed
79
+ // raiser, so no real peer is dropped for want of a raiser. What stops is the
80
+ // silent self-corroboration.
81
+ //
82
+ // ⚠️ WHY THE SEAT COMPARE RUNS FIRST, FOR ANY RAISER, AND NOT INSIDE THE
83
+ // NAMED-RAISER BRANCH. T-B4 round 1 put it inside, on the reading that a
84
+ // NAMED judge always counts beside a falsy raiser. Measured, that left 36
85
+ // cases where a vote carrying the raiser's OWN seat id was counted as its own
86
+ // peer signal — self-corroboration through a second door, the same defect the
87
+ // council raised one layer down. The clause that matters is "provably not the
88
+ // raiser", and a seat id is the strongest proof either way: it decides FIRST,
89
+ // and only when it cannot decide does the judge field get a say.
90
+ //
91
+ // Four spellings were enumerated over the 1875-case truthiness cross-product
92
+ // of (raiser, raiserSeat, judge, seat, verdict) — 5 values for each of the
93
+ // four IDENTITY fields (`undefined`, `null`, `''` and two distinct names or
94
+ // seat ids) times the 3 verdicts, so 5^4 x 3 = 1875, not 5^5 — and scored
95
+ // against P0-P3 above. Only this one reaches zero violations. `64b835b8`
96
+ // breaks P0 in 90 cases and P1 in 567; T-B4 round 1's form breaks P0's peer
97
+ // rule in 90 and its no-mark rule in 108; a "named judge AND not the raiser's
98
+ // own seat" variant breaks them in 54 and 108. The 36-case residual round 1
99
+ // disclosed is measured at ZERO here.
100
+ //
101
+ // ⚠️ FOUR named mutants guard this predicate — SPLITDROP, NAIVESPLIT,
102
+ // SELFCORROB and SEATBLIND. Each mutation, its MEASURED red set and the
103
+ // history of how that set moved are recorded byte-for-byte in
104
+ // tests/council/peer-split-mutants.js :: SPLITDROP, moved out of this file at
105
+ // v4.8 T-B5 because it stood at 289 of 300 lines. RE-RUN them, never renumber
106
+ // them, whenever this expression or its consumers change.
107
+ const peers = votes.filter(v => (v.seat && f.raiserSeat)
108
+ ? v.seat !== f.raiserSeat // P0 — the seats decide
109
+ : f.raiser ? v.judge !== f.raiser // P3 — the alias compare
110
+ : !!v.judge); // P1/P2 — named, or nothing
111
+ // The filter above has THREE branches and they are ORDERED: the seat compare
112
+ // decides first and does not consult `f.raiser` at all, so `peers` never
113
+ // holds a vote carrying the raiser's own seat id, whatever its `judge` says.
114
+ // It CAN hold a seat-carrying vote whose judge is falsy — that is P0
115
+ // admitting a provable peer — which is why tally.js's `sameModelCorroboration`
116
+ // guard still has to reckon with `v.judge === f.raiser` reading
117
+ // `undefined === undefined` on the CLI path (cli-handlers-council.js parses
118
+ // raw JSON with no schema) and `'' === ''` on the MCP path (whose z.string()
119
+ // accepts the empty string). Measured at that guard's own site, its leading
120
+ // `f.raiser &&` is a DECIDER again after round 2.
121
+ return peers;
122
+ }
123
+
124
+ /**
125
+ * How many of `votes` `peersOf` excluded WITHOUT being able to attribute them.
126
+ * Two families, one per FALLBACK arm of `peersOf` (never a seat-decided drop):
127
+ * - raiser NAMED: the drop happened on the ALIAS branch while exactly ONE
128
+ * side of the pair carried a seat id — the finding has a `raiserSeat` and
129
+ * the vote has no `seat`, or the reverse. A seat-less `deepseek` vote
130
+ * cannot be told apart from the raiser's own, so dropping it is the safe
131
+ * call AND may be discarding a real twin's signal (SI-22.1 / SI-22.2).
132
+ * - raiser FALSY (v4.8 T-B4): the vote's `judge` is falsy too, so it may be
133
+ * the unnamed raiser's own. ⚠️ The test is `!v.judge`, NOT
134
+ * `v.judge === f.raiser` — the pair is judged by TRUTHINESS, so a
135
+ * `raiser:''` finding beside a `judge:undefined` vote is the same
136
+ * unattributable pair as `''`/`''` and an `===` spelling would miss it.
137
+ * ⚠️ NEITHER family includes a drop the SEATS decided (P0). When the vote and
138
+ * the finding both carry a seat id the engine knows whose vote it is, so
139
+ * excluding it ATTRIBUTES it rather than losing it, and it is not counted here.
140
+ * That is what the leading `!(v.seat && f.raiserSeat)` says. Marking an
141
+ * attributed drop would make one number mean two different things.
142
+ * This number is what says so out loud instead of leaving the drop silently
143
+ * correct.
144
+ *
145
+ * Both documents call THIS function rather than each spelling the count, so
146
+ * tally.json's mark and the defense brief's mark agree by construction.
147
+ * @param {{raiser?: string, raiserSeat?: string}} f - the finding
148
+ * @param {Array<{judge?: string, seat?: string, verdict?: string}>} votes - its adjudications
149
+ * @returns {number} how many exclusions nobody can attribute (0 when none)
150
+ */
151
+ function unattributedPeerDrops(f, votes) {
152
+ // ⚠️ `f.raiser` was a leading `&&` guard until v4.8 T-B4 and is now the
153
+ // TERNARY CONDITION, because the two FALLBACK arms of `peersOf` drop for two
154
+ // different reasons and this function must count both. The guard existed
155
+ // because a falsy raiser used to drop nothing at all, so any non-zero count
156
+ // would have announced a drop that never happened; T-B4 made those drops real,
157
+ // and the same fixtures that pinned 0 now pin 1.
158
+ //
159
+ // ⚠️ `!(v.seat && f.raiserSeat)` IS NOW A DECIDER AND IS NOW PINNED — retiring
160
+ // a claim this file carried from T-B2 through T-B4 round 1, which read
161
+ // "DOCUMENTATION, not a live test … dropping it flips ZERO … a test claiming
162
+ // to pin it would be green against its own mutant." That was true while it sat
163
+ // inside the named-raiser arm, where the XOR beside it already implied it
164
+ // (exactly one side truthy ⇒ their AND is falsy). Round 2 HOISTED it in front
165
+ // of the ternary to state what P0 requires — a seat-decided exclusion is
166
+ // attributed, so it is never marked — and in the falsy-raiser arm there is no
167
+ // XOR to imply it.
168
+ // RE-MEASURED at round 2 over the 1296-case truthiness cross-product of
169
+ // (f.raiser, f.raiserSeat, v.judge, v.seat), 6 values apiece, three falsy and
170
+ // three truthy: dropping the hoisted conjunct flips 81 cases — ALL 81 in the
171
+ // falsy-raiser arm and ZERO in the named one, so the retired sentence was
172
+ // right about where the conjunct WAS and wrong the moment it moved.
173
+ // Collapsing the ternary to its named arm flips 189 and to its falsy arm 297;
174
+ // dropping the XOR flips 27; dropping `v.judge === f.raiser` flips 270;
175
+ // weakening `!v.judge` to `true` flips 243. All five ARE pinned. The hoisted
176
+ // conjunct's own MEASURED red set, taken by dropping it and running the FULL
177
+ // suite, and RE-RUN at T-B5 fix rounds 2 AND 3: 2 suites / 5 tests, out of
178
+ // 541 / 7674 — peer-split 3 (P0b, P0c and the exhaustive cross-product
179
+ // invariant) and tally 2 (T7b, T7d). UNCHANGED through both, and measured
180
+ // rather than assumed each time: dropping this conjunct is a same-line-count
181
+ // edit, so round 1's volume pin never fired on it and round 3's removal of
182
+ // that pin changed nothing here. Only the denominator moved. An earlier
183
+ // draft of this line said "P0b and P0c", which UNDERSTATED it by three; the
184
+ // count is measured now rather than named from the tests written for it.
185
+ //
186
+ // ⚠️ Named mutant "ZEROEMIT" guards the EMIT rule both callers share — the key
187
+ // is present only when > 0. Its mutation, its MEASURED red set and the record
188
+ // of how that number went stale once already live with the other four, in
189
+ // tests/council/peer-split-mutants.js :: ZEROEMIT. Re-run it, never renumber
190
+ // it, whenever this expression or either producer changes.
191
+ return votes.filter(v => !(v.seat && f.raiserSeat) && (f.raiser
192
+ ? ((!!v.seat !== !!f.raiserSeat) && v.judge === f.raiser)
193
+ : !v.judge)).length;
194
+ }
195
+
196
+ module.exports = { peersOf, unattributedPeerDrops };
@@ -43,11 +43,18 @@ function renderHtml(m) {
43
43
  }).join('');
44
44
  return `<tr style="background:${TIER_VAR[f.tier] || '#fff'}">` +
45
45
  `<td>${esc(f.id)}</td><td>${esc(f.severity)}</td><td>${esc(f.raiser)}</td>${cells}` +
46
- `<td style="color:${TIER_INK[f.tier] || 'inherit'};font-weight:600">${esc(f.tier)}</td>` +
46
+ // v4.8 PR5a T6 (R5-10): the R8 marker on the TIER cell, mirroring renderMd. A
47
+ // SEPARATE test pins each renderer — a shared one would let either regress silently.
48
+ `<td style="color:${TIER_INK[f.tier] || 'inherit'};font-weight:600">${esc(f.tier)}${f.sameModelCorroboration ? '<sup>†</sup>' : ''}</td>` +
47
49
  `<td>${esc(f.decision || '')}</td></tr>`;
48
50
  }).join('');
51
+ // v4.8 SI-22.4 rider (R22.4-6): the seat-keyed label, mirroring renderMd's
52
+ // street-cred loop. A SEPARATE test pins each renderer — a shared one would
53
+ // let either regress silently (the same rule the R8 marker above follows).
54
+ // `seat` is emit-when-DIFFERENT, so a unique-alias bench renders byte-
55
+ // identically to before. Named mutant: tests/council/preset-trim-mutants.js :: ROWSEATDROP.
49
56
  const credRows = m.streetCred.map(s =>
50
- `<tr><td>${esc(s.model)}</td><td>${num(s.peersOnly)}</td><td>${num(s.withSelf)}</td></tr>`).join('');
57
+ `<tr><td>${esc(s.seat || s.model)}</td><td>${num(s.peersOnly)}</td><td>${num(s.withSelf)}</td></tr>`).join('');
51
58
  const tierRows = TIER_ORDER.map(t =>
52
59
  `<tr><td>${t}</td><td>${m.tierCounts[t]}</td></tr>`).join('');
53
60
  const costRows = m.cost.rows.map(r =>
@@ -64,7 +71,7 @@ function renderHtml(m) {
64
71
  // Heading-over-nothing, same guard idiom as debateSection below: absent or
65
72
  // empty degrades ⇒ no section at all, so a clean verdict's HTML stays
66
73
  // byte-identical to before this section existed. Losses are headline news,
67
- // so the section sits directly after the Verdict-summary table (report.js's
74
+ // so the section sits directly after the Verdict-summary table (report-md.js's
68
75
  // renderMd mirrors this placement immediately after the tier loop).
69
76
  const lostSection = lostRows
70
77
  ? `<h2>What was lost</h2><table><tr><th>Channel</th><th>Notice</th></tr>${lostRows}</table>`
@@ -74,7 +81,7 @@ function renderHtml(m) {
74
81
  // renderHtml directly with no debate key) — the guard must tolerate that, and
75
82
  // absent/empty ⇒ no section at all so a no-debate report stays byte-identical
76
83
  // to v4.0's HTML output. no-response findings get their own list (same
77
- // reasoning as report.js's renderMd) so the heading never dangles over
84
+ // reasoning as report-md.js's renderMd) so the heading never dangles over
78
85
  // nothing when a run's only debating raiser never responded.
79
86
  let debateSection = '';
80
87
  if (m.debate && m.debate.present) {
@@ -131,7 +138,7 @@ td.c { text-align: center; }
131
138
  <table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>${lostSection}
132
139
  <h2>Adjudication matrix</h2>
133
140
  <table><tr><th>Finding</th><th>Sev</th><th>Raiser</th>${judgeHead}<th>Tier</th><th>Decision</th></tr>${matrixRows}</table>
134
- <p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>
141
+ <p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>${m.findings.some(f => f.sameModelCorroboration) ? '\n<p class="legend"><sup>†</sup> corroborated only by another seat running the SAME model — concurrence, not independent support.</p>' : ''}
135
142
  <h2>Street-cred <span class="meta">(peers-only; lower = better)</span></h2>
136
143
  <table><tr><th>Model</th><th>peers-only</th><th>with-self</th></tr>${credRows}</table>${debateSection}
137
144
  <h2>Cost</h2>