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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- package/src/workspace/seat-space.js +143 -0
package/src/council/ledger.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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>
|