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
@@ -0,0 +1,262 @@
1
+ // src/council/seats.js
2
+ 'use strict';
3
+ // Seat identity (v4.8 workstream A, spec §4). A SEAT is one bench position:
4
+ // derived, never minted, stable for the life of a run. For every bench that
5
+ // has ever run — no alias repeated — a seat id equals its alias byte-for-byte,
6
+ // which is what keeps run.json, tally.json, verdict.json, report.html,
7
+ // artifact filenames and ledger rows unchanged.
8
+ //
9
+ // This module requires NOTHING, deliberately: its consumers (run-stages,
10
+ // run-launch, run-retry, run-stage2, run-assemble, run.js) all require IT, so
11
+ // any back-require would be a cycle. That is why slug and sanitizeName live
12
+ // here and are re-exported from their previous homes.
13
+
14
+ /** URL/role-safe token from free text (moved from run-stages.js, v4.8 PR1). */
15
+ function slug(text) {
16
+ return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
17
+ }
18
+
19
+ /** Filesystem-safe model name for review-/judge- artifact filenames. */
20
+ function sanitizeName(model) {
21
+ return String(model).replace(/[^a-zA-Z0-9._-]/g, '-');
22
+ }
23
+
24
+ /**
25
+ * Derive the seat table from data run.json already persists (run-state.js
26
+ * seeds bench/critic/lenses). Pure and TOTAL: critic and lenses are
27
+ * schema-optional and a legacy dir may carry a mismatched pair, so this never
28
+ * throws and never guesses.
29
+ *
30
+ * Seat id (spec §4.2): the alias when it occupies exactly one bench seat, else
31
+ * `alias#N` with N 1-based among the seats sharing that alias.
32
+ *
33
+ * Roles follow run-stages.js roleFor's PRECEDENCE — under lenses every seat is
34
+ * a lens seat and 'critic' is unreachable — and match it for every bench the
35
+ * CLI can produce (no repeated alias; lenses.length === bench.length, enforced
36
+ * at cli-handlers-council-run.js:161). They DELIBERATELY diverge where roleFor
37
+ * is wrong: roles here are POSITIONAL, so twins under lenses get their own
38
+ * lens instead of both getting the first twin's (roleFor uses
39
+ * o.models.indexOf). An empty lenses array counts as no lenses (roleFor treats
40
+ * [] as truthy and yields 'lens:undefined'), and a lenses array shorter than
41
+ * the bench leaves the trailing seats plain for the same reason. Do not
42
+ * "restore parity" — the divergence IS the feature, and it is pinned by tests.
43
+ *
44
+ * The `claude` seat is deliberately absent: 'claude' is rejected from --models
45
+ * (run-assemble.js:104-132) and synthesized onto meta.models downstream (:226).
46
+ * seats[] is bench-only — never assume meta.models.length === seats.length.
47
+ *
48
+ * @param {?Array<string>} bench run.json's bench (o.models)
49
+ * @param {?string} critic run.json's critic
50
+ * @param {?Array<string>} lenses positionally bound to bench
51
+ * @returns {Array<{id: string, alias: string, role: string, lens: ?string, position: number}>}
52
+ */
53
+ function buildSeats(bench, critic, lenses) {
54
+ const aliases = Array.isArray(bench) ? bench : [];
55
+ const lensed = Array.isArray(lenses) && lenses.length > 0;
56
+ const counts = new Map();
57
+ for (const a of aliases) { counts.set(a, (counts.get(a) || 0) + 1); }
58
+ const seen = new Map();
59
+ return aliases.map((alias, i) => {
60
+ const n = (seen.get(alias) || 0) + 1;
61
+ seen.set(alias, n);
62
+ const lens = lensed && lenses[i] !== undefined ? lenses[i] : null;
63
+ const role = lensed
64
+ ? (lens === null ? 'seat' : `lens:${slug(lens)}`)
65
+ : (alias === critic ? 'critic' : 'seat');
66
+ return {
67
+ id: counts.get(alias) > 1 ? `${alias}#${n}` : alias,
68
+ alias, role, lens, position: i + 1,
69
+ };
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Role of a seat by its id. Returns 'seat' for an unknown id, matching
75
+ * roleFor's fallthrough for any alias that is not the critic. (roleFor returns
76
+ * 'critic' for an OFF-BENCH critic because run-stages.js:40 is not gated on
77
+ * bench membership; buildSeats never mints a seat for one and preflightSeats
78
+ * rejects that state pre-spend, so 'seat' is right for every run v4.8 creates.)
79
+ * @param {?Array<object>} seats
80
+ * @param {string} seatId
81
+ * @returns {string}
82
+ */
83
+ function roleAt(seats, seatId) {
84
+ const hit = (Array.isArray(seats) ? seats : []).find(s => s && s.id === seatId);
85
+ return hit ? hit.role : 'seat';
86
+ }
87
+
88
+ /**
89
+ * Resolve a wave's legs to its seats. Pure: it reports, it never emits a
90
+ * degrade and never guesses — silent mis-attribution is the failure this whole
91
+ * mechanism exists to kill (spec §4.4).
92
+ *
93
+ * `seats` is THE WAVE'S LAUNCH ROSTER, in launch order — not necessarily the
94
+ * full seat table. The -s1 wave is critic-filtered (run-stage1-launch.js:54) and
95
+ * a retry wave is the loss subset (`run-retry.js :: retryStage1Losses` launches
96
+ * `unit.models`), so a legId's `-N` suffix indexes this roster, never bench position.
97
+ *
98
+ * Callers legitimately hold legs from several waves at once, so a leg stamped
99
+ * with a DIFFERENT waveId is ignored rather than reported — call once per wave
100
+ * over the same array. A leg with NO waveId (result-schema.js:61 falls back to
101
+ * `metadata.parentWave || null`, and the council fixtures omit it) may bind
102
+ * ONLY by an exact roster-slot id: adopting it by alias would silently claim a
103
+ * foreign wave's leg.
104
+ *
105
+ * Resolution order per leg:
106
+ * 1. `leg.legId || leg.taskId` matching `${waveId}-${n}` → roster slot n.
107
+ * Both are read because legId is never persisted, so every disk-rebuilt
108
+ * wave is taskId-only.
109
+ * 2. alias (`leg.modelInput || leg.model`), only for a wave-stamped leg and
110
+ * only when that alias holds exactly one seat in this roster — for every
111
+ * bench that has ever run, this is today's exact behaviour.
112
+ * 3. neither → the leg is an orphan and the seat stays unbound.
113
+ *
114
+ * The greedy `.*` needs no invariant about waveId shapes: it takes the
115
+ * LONGEST prefix before a trailing `-<digits>`, and the `m[1] === waveId`
116
+ * equality then rejects anything that is not precisely this wave. A waveId
117
+ * that itself ends in digits (`r-2`) parses correctly — verified by brute
118
+ * force over adversarial (waveId, legId) pairs. Two CI reviewers read the
119
+ * old wording as a live hazard; it was a defensive note, not a dependency.
120
+ *
121
+ * `bound` says nothing about USABILITY: a leg that ran and died still binds
122
+ * (run-launch.js :: materializeReviews drops non-complete legs later).
123
+ * PR2's dead-seat set is `unbound ∪ deadWave.seats ∪ {bound seats
124
+ * materializeReviews rejected}`.
125
+ *
126
+ * @param {string} waveId
127
+ * @param {?Array<object>} seats the wave's launch roster, in launch order
128
+ * @param {?Array<object>} legs
129
+ * @returns {{bound: Array<{seat: object, leg: object}>, unbound: Array<object>, orphanLegs: Array<object>}}
130
+ */
131
+ function bindSeats(waveId, seats, legs) {
132
+ const roster = Array.isArray(seats) ? seats.filter(Boolean) : [];
133
+ const all = Array.isArray(legs) ? legs.filter(Boolean) : [];
134
+ const mine = all.filter(l => !l.waveId || l.waveId === waveId);
135
+ const takenBy = new Map();
136
+ const bound = [];
137
+ const orphanLegs = [];
138
+ for (const leg of mine) {
139
+ const id = leg.legId || leg.taskId;
140
+ const m = typeof id === 'string' ? id.match(/^(.*)-(\d+)$/) : null;
141
+ let seat = (m && m[1] === waveId) ? roster[Number(m[2]) - 1] : undefined;
142
+ if (!seat && leg.waveId === waveId) {
143
+ const alias = leg.modelInput || leg.model;
144
+ const hits = roster.filter(s => s.alias === alias);
145
+ seat = hits.length === 1 ? hits[0] : undefined;
146
+ }
147
+ if (!seat || takenBy.has(seat.id)) { orphanLegs.push(leg); continue; }
148
+ takenBy.set(seat.id, leg);
149
+ bound.push({ seat, leg });
150
+ }
151
+ return { bound, unbound: roster.filter(s => !takenBy.has(s.id)), orphanLegs };
152
+ }
153
+
154
+ /**
155
+ * The artifact filename for one seat. Reproduces the four shapes shipping
156
+ * today — review-/judge-/rebuttal-/revote-<name>.md, the exact key set
157
+ * workspace/artifact-guard.js enumerates — with the seat id in place of the
158
+ * alias. Identical output for every bench that has ever run; a twin sanitizes
159
+ * to `review-deepseek-2.md`, which is the collision surface preflightSeats
160
+ * rejects pre-spend.
161
+ * @param {{id: string}} seat
162
+ * @param {'review'|'judge'|'rebuttal'|'revote'} kind
163
+ * @returns {string}
164
+ */
165
+ function artifactName(seat, kind) {
166
+ return `${kind}-${sanitizeName(seat && seat.id)}.md`;
167
+ }
168
+
169
+ /**
170
+ * How a seat is named to a human — chair packet review headers today.
171
+ * Deliberately the seat id and nothing more: spec §4.2's byte-identical
172
+ * promise means a unique-alias seat MUST render as its bare alias, so lens
173
+ * text, position and role can never be appended unconditionally. It exists as
174
+ * a named seam so a later rev changes presentation in one place instead of at
175
+ * every call site.
176
+ * @param {{id: string}} seat
177
+ * @returns {string}
178
+ */
179
+ function displayName(seat) {
180
+ return seat && seat.id;
181
+ }
182
+
183
+ const SEATS_ERROR = 'COUNCIL_SEATS_INVALID';
184
+
185
+ /**
186
+ * Mint the seat table and validate it, pre-spend. Runs AFTER initRun (so the
187
+ * error doc lands in a run dir that exists) and BEFORE any launch, exactly like
188
+ * its sibling preflightClaudeReview.
189
+ *
190
+ * Rejects five ways, all zero-spend:
191
+ * - two bench entries resolving to the SAME seat id (a bench alias spelling
192
+ * another alias's disambiguated id, e.g. 'deepseek#2' beside twin
193
+ * 'deepseek' entries) — that table would be incoherent as a join key,
194
+ * - a collision in which a DISAMBIGUATED (#N) id participates, i.e. two seats
195
+ * whose review files would be the same name. Deliberately narrow: a
196
+ * pure-alias collision ('vendor/a' vs 'vendor?a') runs today and
197
+ * workspace/artifact-guard.js exists to detect and surface it, so v4.8
198
+ * refuses only the collisions its own id scheme creates
199
+ * (any '#' in a seat id counts — a literal alias containing '#' is the
200
+ * same collision surface as a minted #N id),
201
+ * - a --critic alias occupying more than one seat,
202
+ * - a critic that is not on the bench at all. runCouncil never checked this
203
+ * (only the CLI/MCP handlers did), so a direct require() caller silently
204
+ * launched an N+1th leg meta.models never mentioned,
205
+ * - --critic together with a non-empty --lenses. Under lenses every seat
206
+ * already carries its own lens role, so criticSeat would name a seat
207
+ * buildSeats gave a `lens:*` role — both handlers already enforce this
208
+ * XOR; the engine now does too. An empty lenses array is not lenses
209
+ * anywhere else in this module (buildSeats' `lensed` check above), so it
210
+ * does not trip this guard.
211
+ *
212
+ * Remedies ride INSIDE message: the engine error is {code, message} and both
213
+ * render paths discard anything else. The message never suggests naming a seat
214
+ * id — every entry point requires bench.includes(critic), so that spelling
215
+ * cannot work until PR2 teaches the handlers.
216
+ *
217
+ * @param {{models: ?Array<string>, critic: ?string, lenses: ?Array<string>}} o
218
+ * @returns {{seats: ?Array<object>, criticSeat: ?string, error: ?{code: string, message: string}}}
219
+ */
220
+ function preflightSeats(o) {
221
+ const bad = (detail) => ({ seats: null, criticSeat: null,
222
+ error: { code: SEATS_ERROR, message: `council_seats_invalid: ${detail}` } });
223
+ const seats = buildSeats(o.models, o.critic, o.lenses);
224
+
225
+ const byId = new Set();
226
+ const byFile = new Map();
227
+ for (const s of seats) {
228
+ if (byId.has(s.id)) {
229
+ return bad(`two bench entries both resolve to seat id '${s.id}' — a bench alias may not `
230
+ + "spell another alias's disambiguated seat id; rename one entry");
231
+ }
232
+ byId.add(s.id);
233
+ const file = artifactName(s, 'review');
234
+ const prev = byFile.get(file);
235
+ // Only reject collisions this id scheme created: at least one side must be
236
+ // a disambiguated id. Pure-alias collisions are artifact-guard's to surface.
237
+ if (prev && (prev.includes('#') || s.id.includes('#'))) {
238
+ return bad(`seats '${prev}' and '${s.id}' would both write ${file} — rename one bench entry`);
239
+ }
240
+ if (!prev) { byFile.set(file, s.id); }
241
+ }
242
+
243
+ if (o.critic && Array.isArray(o.lenses) && o.lenses.length > 0) {
244
+ return bad('--critic and --lenses are mutually exclusive: under lenses every seat carries its '
245
+ + 'own lens role and no seat can be the critic — drop one of the two');
246
+ }
247
+
248
+ if (o.critic) {
249
+ const hits = seats.filter(s => s.alias === o.critic);
250
+ if (hits.length === 0) {
251
+ return bad(`--critic '${o.critic}' is not on the bench (${seats.map(s => s.id).join(', ') || 'empty'})`);
252
+ }
253
+ if (hits.length > 1) {
254
+ return bad(`--critic '${o.critic}' is ambiguous: that alias occupies ${hits.length} bench seats `
255
+ + '— remove the duplicate bench entry, or use two distinct aliases');
256
+ }
257
+ return { seats, criticSeat: hits[0].id, error: null };
258
+ }
259
+ return { seats, criticSeat: null, error: null };
260
+ }
261
+
262
+ module.exports = { buildSeats, roleAt, bindSeats, artifactName, displayName, preflightSeats, slug, sanitizeName };
@@ -0,0 +1,142 @@
1
+ // src/council/stage1-bind.js
2
+ 'use strict';
3
+ // Stage-1 seat binding (v4.8 workstream A, spec §4.4). Lives here rather than in
4
+ // run-stages.js because that file is at 292/300 and this is where the leg<->seat
5
+ // join and its two failure shapes belong together.
6
+
7
+ const { bindSeats } = require('./seats');
8
+
9
+ /**
10
+ * Bind every Stage-1 wave to its own roster, ONE CALL PER WAVE.
11
+ *
12
+ * Never call bindSeats once over the flattened leg array: seats.js:133 admits a
13
+ * leg with no waveId, so a concatenated array makes wave B's legs candidates for
14
+ * wave A's slots. The waves[] entries from launchStage1 are already partitioned
15
+ * by construction (run-stage1-launch.js captures each wave's own `got`).
16
+ *
17
+ * @param {Array<{waveId: string, roster: Array<object>, legs: Array<object>}>} waves
18
+ * @returns {{seatOf: Map<object, object>,
19
+ * missingSeats: Array<{waveId: string, seat: object, returned: number, expected: number}>,
20
+ * orphanLegs: Array<{waveId: string, leg: object}>}}
21
+ */
22
+ function bindStage1Waves(waves) {
23
+ const seatOf = new Map();
24
+ const missingSeats = [];
25
+ const orphanLegs = [];
26
+ for (const w of (Array.isArray(waves) ? waves : [])) {
27
+ const legs = Array.isArray(w.legs) ? w.legs : [];
28
+ const roster = Array.isArray(w.roster) ? w.roster : [];
29
+ const { bound, unbound, orphanLegs: strays } = bindSeats(w.waveId, roster, legs);
30
+ for (const b of bound) { seatOf.set(b.leg, b.seat); }
31
+ for (const leg of strays) { orphanLegs.push({ waveId: w.waveId, leg }); }
32
+ // A wave that returned ZERO legs contributes no missing seats: it is already a
33
+ // dead wave (or a budget refusal, or an abort), each with its own louder
34
+ // channel. A wave that returned legs we could NOT attribute contributes none
35
+ // either: an orphan leg is a review that LANDED — materializeReviews writes it
36
+ // under its alias name — for a seat we cannot name. Retrying that seat would
37
+ // buy a SECOND paid leg and put two reviews on one seat, breaking the
38
+ // invariant run-stages.js:142-151 states. The orphan is already announced on
39
+ // `seat-unbound` at bind time (R-B: orphans are not a loss and not retryable).
40
+ if (legs.length === 0 || strays.length > 0) { continue; }
41
+ for (const seat of unbound) {
42
+ missingSeats.push({ waveId: w.waveId, seat, returned: legs.length, expected: roster.length });
43
+ }
44
+ }
45
+ return { seatOf, missingSeats, orphanLegs };
46
+ }
47
+
48
+ /**
49
+ * A returned leg that matches no roster slot. Announced immediately — unlike a
50
+ * missing seat it is not a loss and there is nothing to retry, and unlike a
51
+ * mis-binding it is exactly the case where guessing would be wrong.
52
+ */
53
+ function orphanLegNote(waveId, leg) {
54
+ const legId = (leg && (leg.legId || leg.taskId)) || 'unidentified';
55
+ const alias = (leg && (leg.modelInput || leg.model)) || 'unknown';
56
+ return {
57
+ channel: 'seat-unbound',
58
+ what: `leg ${legId} in wave ${waveId} matches no seat on that wave's roster`,
59
+ why: `its id names no roster slot of ${waveId}, and its model '${alias}' does not identify `
60
+ + 'exactly one seat there',
61
+ effect: 'Its review is kept under its model name and is NOT attributed to a seat; nothing was '
62
+ + 'guessed and nothing was dropped',
63
+ data: { waveId, legId, seat: alias },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Turn a missing seat into a single-seat dead-wave record so the ordinary SL-2
69
+ * retry machinery relaunches it (owner ruling R-B). `partial: true` is what keeps
70
+ * the prose honest downstream: the wave DID produce legs, so the plain dead-wave
71
+ * sentence would be false.
72
+ */
73
+ function missingSeatDeadWave(m) {
74
+ return {
75
+ waveId: m.waveId,
76
+ models: [m.seat.alias],
77
+ seats: [m.seat],
78
+ // ASCII apostrophe deliberately: this string reaches a terminal through
79
+ // formatDegrade, and a Windows console can mangle U+2019. It was the only
80
+ // curly quote in src/ — and had no caller until v4.8 PR2b Task 7 shipped it.
81
+ reason: `the wave returned ${m.returned} of ${m.expected} legs and none of them was this seat's`,
82
+ partial: true,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Bind ONE wave whose roster may have HOLES: pad the holes, bind, then drop
88
+ * every placeholder bind. Appended (never inserted) by v4.8 SI-27 / R14, which
89
+ * consolidated the block that stood byte-identical at three call sites ONCE `waveId`
90
+ * and the alias lookup are parameterised — site 2's callback was literally
91
+ * `(r, i) => { if (r.seat) … }`, reading the seat off a review, not off a roster
92
+ * slot. The three sites: `run-retry-launch.js :: bindRetryWave`,
93
+ * `run-stage2.js :: runStage2` and `run-debate-revote.js :: runRevoteWave`.
94
+ *
95
+ * How this differs from `bindStage1Waves` above: that one takes MANY waves, each
96
+ * with a REAL roster — no padding, no placeholders, and it owns its own missing/
97
+ * orphan bookkeeping. This one takes ONE wave and a roster source that may carry
98
+ * falsy slots, and owns nothing past the bind.
99
+ *
100
+ * A falsy entry means "we could not identify this seat"; pad it with a
101
+ * position-stable placeholder carrying a UNIQUE id so no slot shifts, then drop
102
+ * the placeholder binds so nothing is guessed. ⚠️ Never pass `rosterSource` raw
103
+ * and never use a null-id sentinel: `seats.js :: bindSeats` filters falsy roster
104
+ * entries internally (so raw === filtered, and both slide every later slot into
105
+ * a hole), and two `{id: null}` sentinels collide on the id-keyed dedup.
106
+ *
107
+ * Placeholders are tracked by IDENTITY, never by an id-name prefix test: a bench
108
+ * alias that literally began `__unbound-` would make a name test drop a REAL
109
+ * seat's binding — a name-collision channel inside the one mechanism whose whole
110
+ * contract is "never guess".
111
+ *
112
+ * `bindRes` and `placeholders` come back raw because the ORPHAN/MISSING tail
113
+ * differs at every call site and STAYS there: site 1 returns `orphanLegs` to its
114
+ * caller, site 2 notes orphans and walks `bindRes.unbound` (skipping
115
+ * `placeholders`) for missing seats, site 3 has no tail at all.
116
+ *
117
+ * No argument guards by design (R27-3): each call site keeps the guard it has.
118
+ *
119
+ * @param {string} waveId
120
+ * @param {Array<?object>} rosterSource one entry per launched slot; falsy where the seat is unknown
121
+ * @param {(i: number) => string} aliasAt the alias that launched in slot i
122
+ * @param {Array<object>} legs the wave's returned legs
123
+ * @returns {{seatOf: Map<object, object>,
124
+ * bindRes: {bound: Array<object>, unbound: Array<object>, orphanLegs: Array<object>},
125
+ * placeholders: Set<object>}}
126
+ */
127
+ function bindPaddedWave(waveId, rosterSource, aliasAt, legs) {
128
+ const placeholders = new Set();
129
+ const roster = rosterSource.map((s, i) => {
130
+ if (s) { return s; }
131
+ const p = { id: `__unbound-${waveId}-${i + 1}`, alias: aliasAt(i), role: 'seat', lens: null, position: i + 1 };
132
+ placeholders.add(p);
133
+ return p;
134
+ });
135
+ const bindRes = bindSeats(waveId, roster, legs);
136
+ const seatOf = new Map(bindRes.bound
137
+ .filter(b => !placeholders.has(b.seat))
138
+ .map(b => [b.leg, b.seat]));
139
+ return { seatOf, bindRes, placeholders };
140
+ }
141
+
142
+ module.exports = { bindStage1Waves, orphanLegNote, missingSeatDeadWave, bindPaddedWave };
@@ -0,0 +1,258 @@
1
+ // src/council/street-cred.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/street-cred
6
+ * The street-cred half of the tally: rank positions, the seat universe, and
7
+ * `computeStreetCred` itself. Split out of ./tally at v4.8 T3.3 because the
8
+ * seat-keying work took that file to 301 of 300 lines — release Constraint 6
9
+ * is EXTRACT, never shave a comment to fit. ./tally re-exports
10
+ * `computeStreetCred`, so no import path in the tree moved.
11
+ *
12
+ * ⚠️ REQUIRE-FREE by design, the ./seats · ./run-stats-entry · ./peer-split
13
+ * precedent: this is a leaf, so the dependency runs one way (tally.js ->
14
+ * street-cred.js) and cannot cycle.
15
+ *
16
+ * ⚠️ EIGHT named mutants guard the expressions below — RANKALIAS, ALIASSELF,
17
+ * JUDGEALIAS, SEATALWAYS, ALIASDRIVER, NOFALLBACK, EXPANDONCE (v4.8
18
+ * follow-up) and PROTORANK (SI-24). Each mutation and its MEASURED red set is
19
+ * recorded beside the others, in tests/council/street-cred-mutants.js ::
20
+ * RANKALIAS and its seven siblings there, following the
21
+ * tests/council/peer-split-mutants.js :: SPLITDROP precedent. RE-RUN them,
22
+ * never renumber them, whenever anything here or any of its consumers
23
+ * changes.
24
+ */
25
+
26
+ function mean(arr) { return arr.reduce((s, x) => s + x, 0) / arr.length; }
27
+
28
+ /**
29
+ * Map each ranked IDENTITY to its (possibly fractional) rank position in one
30
+ * judge's order — its SEAT id where the parallel `orderSeats` channel names
31
+ * one, its alias otherwise.
32
+ *
33
+ * v4.8 T3.3, the first of SI-20's three collapse sites. `pos.set(m, …)` was
34
+ * last-wins BY ALIAS, so a twin bench's `order ['a','a','b']` overwrote the
35
+ * first twin's position 1 with the second's 2 instead of giving each its own
36
+ * entry. MEASURED at BASE `b341b273` on that bench: both `a` street-cred rows
37
+ * came back `withSelf 2.667 / peersOnly 3` off the SAME collapsed map, and
38
+ * `JSON.stringify(row0) === JSON.stringify(row1)` was `true`. Seat keys are
39
+ * what make those two rows diverge — which is why the ledger's join had to be
40
+ * seat-aware in the same commit; the hazard is written out in full at
41
+ * ledger-join.js :: credFor.
42
+ *
43
+ * `order` itself is UNTOUCHED: still alias-valued, still the array the tally
44
+ * schema reads, and still what `briefings-chair.js :: buildChairPacket` falls
45
+ * back to per slot.
46
+ * ⚠️ DO NOT READ THAT AS "orderSeats cannot affect what the chair reads" — it
47
+ * could not when this was written, and v4.8 SI-25 changed it.
48
+ * `briefings-chair.js :: seatKeyedOrder` now zips `orderSeats` OVER `order` to
49
+ * render the chair packet's rankings block, so this channel has a live PROSE
50
+ * consumer besides this function, and a shape change here reaches the one
51
+ * artifact a paid chair reads as authoritative. Check that renderer and its
52
+ * pins (tests/council/chair-packet-seats.test.js) before altering the shape.
53
+ * `orderSeats` mirrors `order` slot for slot — anonymize.js :: rankingToOrder
54
+ * builds both from the same `slots` array in the same pass, so a tie group is
55
+ * an array in both. Where it is absent, shorter, or holds `null` (every
56
+ * pre-T3.2 caller, every unique-alias bench, and any hand-assembled input the
57
+ * seat machinery never touched) `|| group[k]` falls back to the alias and this
58
+ * function returns byte-for-byte what it returned before.
59
+ * @param {Array<string|string[]>} order alias-valued rank slots
60
+ * @param {?Array<?string|Array<?string>>} orderSeats parallel seat ids, null per unseated slot
61
+ */
62
+ function rankPositions(order, orderSeats) {
63
+ const pos = new Map();
64
+ let p = 1;
65
+ for (let i = 0; i < order.length; i += 1) {
66
+ const group = Array.isArray(order[i]) ? order[i] : [order[i]];
67
+ const seatSlot = orderSeats ? orderSeats[i] : null;
68
+ const seatGroup = Array.isArray(seatSlot) ? seatSlot : [seatSlot];
69
+ const meanPos = p + (group.length - 1) / 2;
70
+ for (let k = 0; k < group.length; k += 1) { pos.set(seatGroup[k] || group[k], meanPos); }
71
+ p += group.length;
72
+ }
73
+ return pos;
74
+ }
75
+
76
+ /**
77
+ * The street-cred universe: exactly one entry per `models` OCCURRENCE, never
78
+ * fewer and never more. `seats` NAMES rows; it never changes how many exist.
79
+ *
80
+ * v4.8 follow-up (2026-08-21) — council findings 1+2 on PR #176, reproduced at
81
+ * BASE `13cd0a2d`. The row count used to follow `seats` wherever it disagreed
82
+ * with `models`'s repeat count for an alias: the FIRST occurrence the table
83
+ * named expanded into ALL of that alias's registered ids at once, and every
84
+ * LATER occurrence of the same alias was skipped outright. So an alias the
85
+ * table OVER-registers (more ids than `models` repeats it) INVENTED an extra
86
+ * row on its first occurrence, and an alias it UNDER-registers (fewer ids than
87
+ * `models` repeats it) DROPPED a row on its later occurrence(s) once the
88
+ * table's ids for it ran out. Both are reachable only on the two
89
+ * hand-assembled `appendRun` paths — `mcp-tools.js :: amicus_council_tally`
90
+ * declares `meta.seats` independently of `meta.models` and
91
+ * `cli-handlers-council.js` passes user JSON through verbatim — never on the
92
+ * engine's own output. ⚠️ NOT because `seats[]` and `models` always agree —
93
+ * they do not: `meta.models` carries a `claude` tail on a `claudeInCouncil`
94
+ * run (synthesized downstream of `buildSeats`, seats.js:44-45) that `seats[]`
95
+ * never names, so the two can differ in LENGTH. What DOES hold, for every
96
+ * alias `seats[]` was built FROM: `seats.js :: buildSeats` is
97
+ * `aliases.map(...)` over the bench array `models` is built from, so it is
98
+ * structurally one-to-one with that bench and cannot itself over- or
99
+ * under-register any bench alias, independent of anything `preflightSeats`
100
+ * separately rejects (id collisions, critic placement). The `claude` tail
101
+ * falls to the `!ids` branch below exactly like an alien alias does — a
102
+ * shortfall this function already handles by design, not a counterexample to
103
+ * it (pinned as the "claude tail" case, both here and in the invariant table
104
+ * at street-cred.test.js).
105
+ *
106
+ * FIX: the k-th occurrence of alias `m` in `models` takes the k-th id
107
+ * `byAlias.get(m)` registered for it, in table order. Once that list is
108
+ * exhausted (or the table never named `m` at all) the occurrence gets an
109
+ * ALIAS-KEYED row (`key: m, seat: null`) instead of being dropped, and a
110
+ * surplus registered id simply goes unused instead of being invented into an
111
+ * extra row — so `credSeats(models, seats).length === models.length` always.
112
+ *
113
+ * ⚠️ ROW ORDER, SEPARATELY — review round 1 of this task, Important 1. This
114
+ * function's row order is now `models` order, always: each occurrence pushes
115
+ * exactly where its own index puts it. The pre-fix loop ALSO happened to
116
+ * group an alias's rows at its first occurrence (`expanded.has(m) ->
117
+ * continue` skipped every later one), so on a NON-ADJACENT repeat — an
118
+ * ordinary engine bench, e.g. `['a','b','a']`, `buildSeats` handles it fine —
119
+ * this function's order now differs from BASE's even though every ADJACENT
120
+ * case in this file's own tests (and the ones street-cred.test.js pins) never
121
+ * showed it. Content is identical either way, only order moves; RULING
122
+ * (owner): the new `models`-order form is correct — BASE's grouping was an
123
+ * accident of the buggy loop, never a stated doctrine, and this order already
124
+ * agrees with `meta.seats`/`position`, which are in `models` order too. Full
125
+ * measurement, the mutant that pins it (EXPANDONCE), and the ruling's
126
+ * reasoning are at street-cred.test.js's "non-adjacent repeat" tests and
127
+ * tests/council/street-cred-mutants.js :: EXPANDONCE.
128
+ *
129
+ * ⚠️ JOINED BY VALUE — never positionally. run-assemble.js :: buildTallyInput
130
+ * forbids the positional join in-code and the reasons are all live here:
131
+ * `seats[]` is BENCH-ONLY, `meta.models` carries a `claude` tail it never has,
132
+ * and two of appendRun's three call sites feed hand-assembled input no seat
133
+ * machinery touches — exactly the input shape where `models` and `seats` can
134
+ * disagree in COUNT, not merely in content.
135
+ * @param {string[]} models meta.models (the ALIAS list)
136
+ * @param {?Array<{id: string, alias: string}>} seats meta.seats, absent on most runs
137
+ * @returns {Array<{model: string, key: string, seat: ?string}>} always
138
+ * `models.length` entries. `key` is what rankPositions keyed by; `seat` is
139
+ * the id, null unless it DIFFERS from the alias — the emit-when-DIFFERENT
140
+ * predicate every seat producer shares (run-stats-entry.js ::
141
+ * buildRunStatsEntry), so a unique-alias bench emits no seat field anywhere
142
+ * and its documents stay byte-identical.
143
+ */
144
+ function credSeats(models, seats) {
145
+ const byAlias = new Map();
146
+ for (const s of (Array.isArray(seats) ? seats : [])) {
147
+ if (!s || typeof s.alias !== 'string' || typeof s.id !== 'string') { continue; }
148
+ if (!byAlias.has(s.alias)) { byAlias.set(s.alias, []); }
149
+ byAlias.get(s.alias).push(s.id);
150
+ }
151
+ // Named mutant "EXPANDONCE": revert this whole loop to the pre-fix form —
152
+ // the first occurrence of a named alias expands into EVERY id it has
153
+ // registered, and `expanded.has(m) -> continue` skips every later
154
+ // occurrence outright. That is a wholesale swap rather than a token flip
155
+ // because the two algorithms track different state (a Set of aliases
156
+ // already expanded vs. a per-alias occurrence count) — there is no smaller
157
+ // edit that reproduces the drop/invent pair. tests/council/street-cred-mutants.js :: EXPANDONCE.
158
+ const seen = new Map(), rows = [];
159
+ for (const m of models) {
160
+ const k = seen.get(m) || 0;
161
+ seen.set(m, k + 1);
162
+ // Named mutant "ALIASDRIVER": force `id` to always be falsy, so the table
163
+ // built above is never consulted and every row is alias-keyed —
164
+ // tests/council/street-cred-mutants.js :: ALIASDRIVER.
165
+ const id = (byAlias.get(m) || [])[k];
166
+ rows.push(id ? { model: m, key: id, seat: id === m ? null : id }
167
+ : { model: m, key: m, seat: null });
168
+ }
169
+ return rows;
170
+ }
171
+
172
+ /**
173
+ * Both-numbers street-cred, ONE ROW PER SEAT. Lower mean rank = better.
174
+ *
175
+ * v4.8 T3.3 closes SI-06 and the rest of SI-20 here. Three things move, and
176
+ * all three are no-ops on a bench with no repeated alias:
177
+ *
178
+ * 1. THE DRIVER is `credSeats` above, not `models.map`. A twin bench used to
179
+ * emit two BYTE-IDENTICAL rows for the one alias (measured at BASE); it now
180
+ * emits one row per seat, each carrying its own numbers and its own `seat`.
181
+ * 2. THE PEER SPLIT was `judge !== m`, the THIRD alias comparison in the engine
182
+ * (SI-06). It now compares SEATS when both sides carry one and aliases
183
+ * otherwise — the same two-branch shape as peer-split.js :: peersOf (P0 then
184
+ * P3), reused rather than re-derived. ⚠️ CONTROLLER RULING (ledger C-2):
185
+ * self-exclusion drops only the judge that IS this seat; the twin's OTHER
186
+ * seat COUNTS AS A PEER. That matches peersOf's P0 branch, where a vote
187
+ * whose seat differs from the raiser's is a real peer, and the
188
+ * `sameModelCorroboration` stamp in tally.js :: tally already exists to mark
189
+ * such support as concurrence rather than independence. Counting-and-marking
190
+ * is this release's settled shape (owner ruling R2). The alias arm is what a
191
+ * bench with no seat channel keeps, so today's numbers do not move there.
192
+ * 3. `perJudgeRank` was `[judge] = rank` — last-wins by judge alias while
193
+ * `all`/`peers` accumulate into ARRAYS, so on a twin-JUDGE bench the map and
194
+ * the averages disagreed about the SAME row (the phasing doc's row `| 24 |`
195
+ * data-loss site). MEASURED at BASE on judges `['a','a','b']` all ranking
196
+ * `a`: `perJudgeRank {"a":3,"b":3}` — 2 entries for 3 judges, implying mean
197
+ * 3, while `withSelf` reported 2.667. Keying on the judge's SEAT closes it
198
+ * wherever a seat channel exists, which is every engine-produced twin bench
199
+ * (run-assemble.js emits `rankings[].seat` for both twins).
200
+ * ⚠️ RESIDUAL, stated rather than left to be discovered: hand-assembled or
201
+ * MCP input that repeats a judge alias and carries NO seat still collapses,
202
+ * because nothing in that document can tell the two judges apart. Ruling R2
203
+ * governs — attribute nothing where there is nothing to attribute.
204
+ * 4. `perJudgeRank` is ALSO `Object.create(null)` now, not a bare `{}` — a
205
+ * WRITE-site defect, separate from item 3's alias-collapse one and unlike
206
+ * a READ-site one (this release's other prototype fix, tally.js ::
207
+ * VERDICTS). `perJudgeRank[j.seat || j.judge] = rank` assigning through
208
+ * the string key `__proto__` invokes the INHERITED `Object.prototype`
209
+ * `__proto__` setter instead of creating an own property; the setter
210
+ * silently ignores a non-object value, so the rank is discarded — no
211
+ * throw, no own key to read back. `toString`/`constructor`/`valueOf` are
212
+ * unaffected — they shadow harmlessly and DO create own keys — so only
213
+ * `__proto__` was ever lost here. MEASURED at BASE, judge `__proto__`:
214
+ * `perJudgeRank` came back `{}` (`Object.keys` empty) while `all`/
215
+ * `withSelf` still counted the vote (`withSelf` 1) — map and average
216
+ * disagreeing about the SAME row, exactly as item 3 describes for the
217
+ * alias-collapse half. A `__proto__` SEAT id is lost the same way: both
218
+ * channels feed the one assignment.
219
+ *
220
+ * @param {Array<{judge:string, order:Array<string|string[]>, seat?:string,
221
+ * orderSeats?:Array<?string|Array<?string>>}>} rankings
222
+ * @param {string[]} models all reviewed models (incl. claude when in-council)
223
+ * @param {?Array<{id: string, alias: string}>} seats meta.seats, when the document has one
224
+ */
225
+ function computeStreetCred(rankings, models, seats) {
226
+ const judgePos = rankings.map(r => ({
227
+ judge: r.judge, seat: r.seat || null, pos: rankPositions(r.order, r.orderSeats),
228
+ }));
229
+ return credSeats(models, seats).map(({ model, key, seat }) => {
230
+ const all = [], peers = [], perJudgeRank = Object.create(null);
231
+ for (const j of judgePos) {
232
+ // Seat key first, ALIAS SECOND. `meta.seats` and `rankings[].orderSeats`
233
+ // are independent channels: a document can carry the seat table while its
234
+ // rankings are alias-only (both hand-assembled `appendRun` paths, and an
235
+ // MCP caller that omits orderSeats). Without the fallback both seat rows
236
+ // would find nothing in an alias-keyed map and report null street cred —
237
+ // strictly worse than the collapse this task exists to fix. With it they
238
+ // read the same collapsed position today's code gives, and only the new
239
+ // `seat` field distinguishes them.
240
+ const rank = j.pos.has(key) ? j.pos.get(key) : j.pos.get(model);
241
+ if (rank === undefined) { continue; } // absent from this judge's ranking → skip
242
+ perJudgeRank[j.seat || j.judge] = rank;
243
+ all.push(rank);
244
+ if ((j.seat && seat) ? j.seat !== seat : j.judge !== model) { peers.push(rank); }
245
+ }
246
+ return {
247
+ model,
248
+ withSelf: all.length ? mean(all) : null,
249
+ peersOnly: peers.length ? mean(peers) : null,
250
+ perJudgeRank,
251
+ // Last, matching verdict.js :: buildVerdict's streetCred literal, which
252
+ // already carries this field through under the same predicate (T3.2).
253
+ ...(seat ? { seat } : {}),
254
+ };
255
+ });
256
+ }
257
+
258
+ module.exports = { computeStreetCred, rankPositions, credSeats };