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
@@ -7,22 +7,35 @@
7
7
  * the `--claude-review` pre-flight it now also owns: the five-keys tally input
8
8
  * (meta pins: claudeInCouncil false, models = bench seats exactly — critic
9
9
  * included, chair excluded — runType 'headless'), runStats rows copied
10
- * verbatim from leg docs, the run-dir artifact set (tally-input.json,
11
- * tally.json, verdict.json with overallVerdict, report.html, chair-output.md),
10
+ * verbatim from leg docs, the tally half of the run-dir artifact set
11
+ * (tally-input.json, tally.json the verdict half moved to
12
+ * ./run-verdict-files and is re-exported below), the chair packet,
12
13
  * v4.1 §4.4 pre-flight validation of the file-sourced Claude review
13
14
  * (preflightClaudeReview — the reserved-seat/chair/critic guards), its review-N+1
14
15
  * labelling (labelClaudeReview), and its synthesized null-usage runStats row
15
16
  * (claudeRunStatsRow). Raiser self-votes are INCLUDED in adjudications —
16
- * exclusion is tally's job (tally.js:95); judged is tally's job (tally.js:110).
17
+ * exclusion is tally's job (tally.js :: tally its `peers` filter, and `judged`).
17
18
  */
18
19
 
19
20
  const fs = require('fs');
20
21
  const path = require('path');
21
22
  const { writeFileAtomic } = require('../utils/atomic-write');
22
- const { buildVerdict, summarizeSeatLoss, deriveSeatLoss, writeVerdictAtomic } = require('./verdict');
23
- const { buildReport } = require('./report');
24
23
  const { validateFindings } = require('./findings');
25
24
  const { toGlobalFindings } = require('./anonymize');
25
+ // Seat identity lives in ./seats (v4.8 PR1) — that module is require-free by
26
+ // design, so preflightSeats' body lives there and is re-exported here to keep
27
+ // the asm.preflightSeats(o) call spelling and this file under the size gate.
28
+ const { preflightSeats } = require('./seats');
29
+ // Same precedent (v4.8 PR4c): writeVerdictFiles' body lives in
30
+ // ./run-verdict-files — with the ./verdict and ./report requires it was the
31
+ // sole consumer of — and is re-exported here so the asm.writeVerdictFiles(...)
32
+ // call spelling and every existing test survive the move untouched.
33
+ const { writeVerdictFiles } = require('./run-verdict-files');
34
+ // Same precedent (v4.8 Phase 1 T1.1): buildRunStatsEntry's body lives in
35
+ // ./run-stats-entry — which is require-free so consumers outside this file's
36
+ // graph can use it — and is re-exported here so every existing call spelling
37
+ // survives the move untouched.
38
+ const { buildRunStatsEntry } = require('./run-stats-entry');
26
39
 
27
40
  const CONFORMANCE_RANK = { clean: 0, repaired: 1, unstructured: 2 };
28
41
 
@@ -35,42 +48,6 @@ function worseConformance(a, b) {
35
48
  return (CONFORMANCE_RANK[a] || 0) >= (CONFORMANCE_RANK[b] || 0) ? a : b;
36
49
  }
37
50
 
38
- /**
39
- * One runStats row from a leg run document. Verbatim copies only — a missing
40
- * leg doc yields durationMs/usage null (never invent a value). `model` (the
41
- * council alias) overrides leg.model (the resolved executable id) so ledger
42
- * rows join meta.models by exact string (ledger.js:20-24).
43
- * `resolvedModel` (v4.7 GOA-7) preserves leg.model — the executable id that
44
- * actually served, post-fallback-substitution — emit-only-when-set and never
45
- * sourced from modelInput (an alias must never masquerade as a resolved id).
46
- *
47
- * ⚠️ LC-11 / review F1: `findingsUnverified` and `repairRefused` are the same
48
- * class of fact as `conformance` and ride the same row. They are the two halves
49
- * of the repair contract's outcome: `findingsUnverified` marks a 'repaired' seat
50
- * whose contract could NOT be checked (the original block was absent or
51
- * unparseable, so there was no finding count to compare), and `repairRefused`
52
- * ({code, detail}) marks the stronger case — the contract WAS checked and broken,
53
- * which is otherwise indistinguishable from a seat that never emitted JSON at
54
- * all. Both are additive and present only when set, so a run without either is
55
- * byte-for-byte unchanged.
56
- */
57
- function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsUnverified,
58
- repairRefused }) {
59
- return {
60
- model: model !== undefined ? model : (leg ? leg.model : null),
61
- role,
62
- wasChair: !!wasChair,
63
- conformance: conformance || 'clean',
64
- ...(findingsUnverified ? { findingsUnverified: true } : {}),
65
- ...(repairRefused ? { repairRefused } : {}),
66
- ...(leg && leg.waveId ? { waveId: leg.waveId } : {}),
67
- ...(leg && leg.model ? { resolvedModel: leg.model } : {}),
68
- status: leg ? leg.status : 'error',
69
- durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
70
- usage: (leg && leg.usage) || null,
71
- };
72
- }
73
-
74
51
  /**
75
52
  * Pre-flight for `--claude-review <path>` (v4.1 §4.4). Runs AFTER initRun (so the
76
53
  * error doc lands in a run dir that exists) and BEFORE any launch, so an invalid
@@ -135,8 +112,9 @@ function claudeRunStatsRow() {
135
112
  * Assemble the five-keys tally input (spec §5 / SKILL.md Stage-2 recipe).
136
113
  * @param {{runId: string, date: string, bench: string[], chair: string,
137
114
  * reviews: Array<{model, role, conformance, leg, globalFindings}>,
138
- * judgeResults: Array<{judge, ok, order, adjudications}>,
139
- * chairStats: object|null, claudeReview?: object|null, extraRows?: Array<object>}} args
115
+ * judgeResults: Array<{judge, seat, ok, order, orderSeats, adjudications, conformance, leg}>,
116
+ * chairStats: object|null, claudeReview?: object|null, extraRows?: Array<object>,
117
+ * seats?: Array<object>}} args
140
118
  * `claudeReview` (v4.1 §4.4) amends the v4.0 meta pin: present ⇒ claudeInCouncil
141
119
  * true, 'claude' joins meta.models (the street-cred universe), its findings join
142
120
  * the pool and it gets the synthesized null-usage runStats row. Absent ⇒ v4.0
@@ -144,23 +122,74 @@ function claudeRunStatsRow() {
144
122
  * (repair/superseded/dead-seat-error, from runStage1 today) appended right
145
123
  * after the primary review rows, before judge/chair accounting — absent or
146
124
  * empty ⇒ byte-for-byte unchanged, so the pre-v4.7 length-7 pins stay green.
125
+ * `seats` (v4.8 PR4c §3.2) is run.json's seat table; see meta.seats below.
147
126
  */
148
127
  function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats,
149
- claudeReview, extraRows }) {
128
+ claudeReview, extraRows, seats }) {
150
129
  const meta = {
151
130
  runId, date, runType: 'headless',
152
131
  models: bench.slice(), // bench seats exactly: critic included, chair excluded
153
132
  chair,
154
133
  claudeInCouncil: false, // pinned for headless runs
134
+ // v4.8 PR4c §3.2 (R4c-1 as amended by R4c-7): the seat table, a pure TAIL so
135
+ // the shipped six-key order above is byte-identical in every case. Emitted
136
+ // ONLY when the bench repeats an alias — the one case where the `alias#N`
137
+ // seat ids on findings[].raiserSeat, adjudications[].seat and runStats[].seat
138
+ // resolve to nothing else in the document (meta.models is the ALIAS list).
139
+ // ⚠️ `seats ?` alone would be VACUOUS: run.js:133 sets o.seats unconditionally
140
+ // past the preflight and buildSeats always returns an ARRAY ([] is still
141
+ // truthy), so that spelling writes a full table into tally-input.json,
142
+ // tally.json AND verdict.json on every unique-alias bench.
143
+ // ⚠️ NARROW BY RULING, not by oversight. The guard asks "does the bench
144
+ // repeat an alias?", which is a DIFFERENT question from "is anything in
145
+ // seats[] unrecoverable?" — they part on a UNIQUE-alias lens/critic bench.
146
+ // `position` and the raw lens text (runStats[].role has only the slug) ride
147
+ // in meta.seats EXACTLY when it ships, i.e. on a repeated-alias bench, and
148
+ // are absent BY DESIGN otherwise (measured 2026-08-16; BACKLOG SI-21, HOLD).
149
+ // ⚠️ Consumers: absence means "no seat table available", NEVER "the bench was
150
+ // unique" — two of appendRun's three call sites feed hand-assembled input no
151
+ // seat machinery touches. And seats[] is BENCH-ONLY, so it must never be
152
+ // joined positionally to meta.models (`claude` is pushed onto that inside
153
+ // run-assemble.js :: buildTallyInput) or to streetCred[].
154
+ // .slice() is defence-in-depth only: the array is shared with
155
+ // runState.checkpoint (run.js:135) and with both tally inputs. Nothing
156
+ // mutates meta.seats — unlike models, which meta.models.push mutates below —
157
+ // so no test can distinguish the copy from the reference.
158
+ ...(Array.isArray(seats) && seats.some(s => s.id !== s.alias) ? { seats: seats.slice() } : {}),
155
159
  };
156
160
  const findings = reviews.flatMap(r => r.globalFindings);
157
161
  const okJudges = judgeResults.filter(j => j.ok);
158
162
  const adjudications = okJudges.flatMap(j =>
159
- j.adjudications.map(a => ({ findingId: a.id, judge: j.judge, verdict: a.verdict })));
160
- const rankings = okJudges.map(j => ({ judge: j.judge, order: j.order }));
163
+ j.adjudications.map(a => ({ findingId: a.id, judge: j.judge, verdict: a.verdict,
164
+ // v4.8 PR3 Task 5, re-based by PR4c R4c-9: emit-when-DIFFERENT against
165
+ // the seat's OWN alias, matching buildRunStatsEntry and run.js's
166
+ // raiserSeat call site. It was `!== j.judge` — the alias as the LEG
167
+ // reported it — which emitted a seat id equal to its own alias whenever
168
+ // the two strings drifted (a padded --council member; a leg with no
169
+ // modelInput), i.e. exactly where the field carries no information.
170
+ ...(j.seat && j.seat.id !== j.seat.alias ? { seat: j.seat.id } : {}) })));
171
+ // v4.8 T3.2: the SAME predicate as the adjudication map immediately above —
172
+ // the judge's own seat, emit-when-DIFFERENT. `order` itself is untouched
173
+ // (still alias-valued).
174
+ // v4.8 T3.3 carries `orderSeats` the one further hop T3.2 deliberately
175
+ // stopped short of: it reached judgeResults[] in run-stage2.js, but
176
+ // `computeStreetCred` reads `rankings`, so without this the seat channel
177
+ // never arrives where SI-20's collapse happens.
178
+ // ⚠️ EMIT ONLY WHEN IT CARRIES AT LEAST ONE NON-NULL. rankingToOrder returns
179
+ // a PARITY SHAPE, not an absence: on a unique-alias bench `orderSeats` is
180
+ // `[null, null, null]`, and emitting that would add a key to rankings[] in
181
+ // tally-input.json, tally.json and tally-provisional.json on every run that
182
+ // has ever happened. `.flat()` is depth-1 because a tie group is the only
183
+ // nesting rankingToOrder can produce (anonymize.js :: rankingToOrder maps
184
+ // one level of `Array.isArray(slot)`). The named mutant that guards this
185
+ // predicate is tests/council/street-cred-mutants.js :: EMITSET.
186
+ const rankings = okJudges.map(j => ({ judge: j.judge, order: j.order,
187
+ ...(Array.isArray(j.orderSeats) && j.orderSeats.flat().some(Boolean)
188
+ ? { orderSeats: j.orderSeats } : {}),
189
+ ...(j.seat && j.seat.id !== j.seat.alias ? { seat: j.seat.id } : {}) }));
161
190
  const runStats = reviews.map(r => buildRunStatsEntry({
162
191
  leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
163
- findingsUnverified: r.findingsUnverified, repairRefused: r.repairRefused,
192
+ findingsUnverified: r.findingsUnverified, repairRefused: r.repairRefused, seat: r.seat,
164
193
  }));
165
194
  // v4.7 D2/E4: pre-built rows (repair/superseded/dead-seat-error) ride right
166
195
  // after the primary review rows — same "primary-adjacent" shape, just not
@@ -177,9 +206,15 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
177
206
  // One row per judge, attributing the judge's ORIGINAL Stage-2 wave leg (never
178
207
  // a repair solo's — run-stage2.js mirrors Stage-1's convention there); a judge
179
208
  // whose wave leg died still gets an honest error row.
209
+ // v4.8 PR5a T4 (R5-8): the judge row carries its SEAT. PR4c withheld it because
210
+ // `joinsLedger` has no 'judge' member, so nothing consumed it; report.js's cost
211
+ // table does now, and without it a twin bench's two judge rows are identical.
212
+ // `buildRunStatsEntry` (run-stats-entry.js :: buildRunStatsEntry) applies the
213
+ // shared emit-when-DIFFERENT predicate, so a unique bench stays byte-identical
214
+ // and no new predicate enters the tree.
180
215
  for (const j of (judgeResults || [])) {
181
216
  runStats.push(buildRunStatsEntry({
182
- leg: j.leg, model: j.judge, role: 'judge', conformance: j.conformance,
217
+ leg: j.leg, model: j.judge, role: 'judge', conformance: j.conformance, seat: j.seat,
183
218
  }));
184
219
  }
185
220
  if (chairStats) { runStats.push(chairStats); }
@@ -194,36 +229,6 @@ function writeTallyFiles({ runDir, tallyInput, record }) {
194
229
  JSON.stringify(record, null, 2), { mode: 0o600 });
195
230
  }
196
231
 
197
- /**
198
- * Undecided verdict + deterministic report. Sets the nullable overallVerdict
199
- * (council family v2, Plan A) on buildVerdict's output — independent of
200
- * buildVerdict's own signature.
201
- * @param {{runDir: string, record: object, overallVerdict?: (string|null),
202
- * chairText?: string, critic?: string, deadWaves?: Array<object>,
203
- * degrades?: Array<object>}} o `degrades` (v4.6 Plan 2), when present, is
204
- * both carried onto the verdict and used to DERIVE `seatLoss` (deriveSeatLoss)
205
- * in preference to summarizing it from `deadWaves` (summarizeSeatLoss).
206
- * @returns {object} the verdict written to disk
207
- */
208
- function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves, degrades }) {
209
- // v4.6 Plan 2 (spec D3): when the sink's records are available they are the
210
- // single source of truth — seatLoss derives from them so it can never
211
- // disagree with degrades[]. deadWaves remains the fallback for direct
212
- // callers that predate the sink (their tests pass unedited).
213
- const seatLoss = degrades
214
- ? deriveSeatLoss({ runId: record.meta.runId, critic, degrades })
215
- : summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
216
- const verdict = buildVerdict(record, [], { seatLoss, degrades });
217
- verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
218
- writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
219
- const html = buildReport({ verdict }, { format: 'html' });
220
- fs.writeFileSync(path.join(runDir, 'report.html'), html, { mode: 0o600 });
221
- if (chairText) {
222
- fs.writeFileSync(path.join(runDir, 'chair-output.md'), chairText, { mode: 0o600 });
223
- }
224
- return verdict;
225
- }
226
-
227
232
  /**
228
233
  * Build the chair packet and persist it as `chair-packet.md`. Lifted verbatim
229
234
  * out of run.js for the 300-line gate (v4.4.1 Task 0.5) — same composition,
@@ -240,10 +245,21 @@ function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, recor
240
245
  const packet = buildChairPacket({
241
246
  // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
242
247
  // no rankings/adjudications, so it appears ONLY as one more review block.
243
- reviews: reviews.map(r => ({ model: r.model, text: r.text }))
248
+ // The projection is DELIBERATE it drops findings/conformance/role/leg; v4.8
249
+ // SI-25 adds `seat`, ⚠️ EMIT-WHEN-DIFFERENT like rankings/adjudications above:
250
+ // `r.model` is the leg's `modelInput || model`, which falls back to the RESOLVED
251
+ // id, so an unconditional forward breaks §4.2 byte identity on a NO-TWIN bench.
252
+ // Mutant: tests/council/chair-packet-seat-mutants.js :: HDRSEATFWD.
253
+ // ⚠️ The Claude review keeps NO seat and renders `claude` via the fallback.
254
+ reviews: reviews.map(r => ({ model: r.model, text: r.text,
255
+ ...(r.seat && r.seat.id !== r.seat.alias ? { seat: r.seat } : {}) }))
244
256
  .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
245
257
  rankings: tallyInput.rankings,
246
258
  adjudications: tallyInput.adjudications,
259
+ // v4.8 PR5a T7: `record` is the TALLIED record, so its findings already carry
260
+ // tally.js's R8 stamp. Passing the array the packet already has access to costs one
261
+ // line here and keeps every rendering decision in briefings-chair.js.
262
+ findings: record.findings,
247
263
  tierCounts: record.tierCounts, date,
248
264
  }) + (debateOutcomes ? '\n\n' + buildDebateAddendum({ outcomes: debateOutcomes }) : '');
249
265
  fs.writeFileSync(path.join(runDir, 'chair-packet.md'), packet, { mode: 0o600 });
@@ -254,4 +270,9 @@ module.exports = {
254
270
  buildRunStatsEntry, worseConformance, buildTallyInput, writeTallyFiles, writeVerdictFiles,
255
271
  buildChairPacketFile,
256
272
  preflightClaudeReview, labelClaudeReview, claudeRunStatsRow, CLAUDE_SEAT,
273
+ preflightSeats,
274
+ // Exported for ledger.js's drift guard only (v4.8 PR4b council A1): ledger.js
275
+ // keeps a LOCAL copy of this table, and the guard deep-compares the two so a
276
+ // level added here can never silently go missing there.
277
+ CONFORMANCE_RANK,
257
278
  };
@@ -53,9 +53,25 @@ function pickFallbackChair(statsRows, bench, failedChair) {
53
53
  // aggregate shapes only.
54
54
  const names = (r) => [r.model, ...(Array.isArray(r.aliases) ? r.aliases : [])];
55
55
  const excluded = (r) => names(r).some(n => n === 'claude' || benchSet.has(n) || n === failedChair);
56
+ // v4.8 PR4a: every sort term is read off the row, so CANDIDATE SELECTION is
57
+ // independent of the order `statsRows` arrives in — previously that order
58
+ // (deriveReliability's Map insertion order = council-ledger.jsonl row order)
59
+ // silently decided every exact street-cred tie, and such ties are an ordinary
60
+ // arithmetic outcome. Terms: street cred (lower mean rank = better), then
61
+ // council appearances, then model id for a guaranteed total order. ⚠️ `runs`
62
+ // is the count of DISTINCT runIds across the group's ledger rows (v4.8 PR4b
63
+ // R4b-1, ledger-stats.js's countRuns) — one council run contributes 1 however many
64
+ // seats that executable filled, and rows from `judged:false` runs that
65
+ // contributed no street cred still count. It is a tie-break, never a ranking
66
+ // signal. Always present on deriveReliability output; the default serves
67
+ // fixtures.
68
+ // Full rationale + the tie arithmetic: tests/council/run-chair.test.js.
69
+ const runsOf = (r) => (typeof r.runs === 'number' ? r.runs : 0);
56
70
  const candidates = (statsRows || [])
57
71
  .filter(r => !excluded(r) && typeof r.avgStreetCredPeersOnly === 'number')
58
- .sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
72
+ .sort((a, b) => (a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly)
73
+ || (runsOf(b) - runsOf(a))
74
+ || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0));
59
75
  if (!candidates.length) { return null; }
60
76
  const top = candidates[0];
61
77
  return (Array.isArray(top.aliases) && top.aliases.length) ? top.aliases[0] : top.model;
@@ -0,0 +1,268 @@
1
+ // src/council/run-debate-revote.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module council/run-debate-revote
6
+ * v4.8 PR3 Task 1: `legOpts`, `legRow`, and `runRevoteWave` extracted verbatim
7
+ * out of run-debate.js (283/300 — the 300-line-per-file gate) with NO
8
+ * behaviour change. run-debate.js requires all three back; runDebate still
9
+ * calls runRevoteWave for the re-vote mini-wave (spec §5.1).
10
+ *
11
+ * That "verbatim" claim held for all three only through Task 1. `legOpts` and
12
+ * `legRow` are still byte-identical to the extraction. `runRevoteWave` is NOT:
13
+ * PR3 Task 6 gave it real seat-binding behaviour — the padded roster + bind
14
+ * (`stage1-bind.js :: bindPaddedWave` since v4.8 SI-27; an inline `bindSeats`
15
+ * call before it), the seat-keyed `byJudge`, the `sanitizeName`'d per-seat
16
+ * repair id, and the `seat` field on the pushed legs (see the function's own
17
+ * docblock below). Do not treat `runRevoteWave` as a behaviour-neutral mirror
18
+ * of the old run-debate.js code — only `legOpts`/`legRow` still are.
19
+ *
20
+ * `isAbortExit` comes from ./run-launch, NEVER from ./run-stages: run-stage2.js:12
21
+ * records that taking it from run-launch.js "is what dissolved the old cycle
22
+ * (v4.4.1 review F5)". Requiring ./run-stages from this new leaf would drag in
23
+ * run-retry → run-retry-notes → briefings and re-open that cycle class.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const dbrief = require('./briefings-debate');
29
+ const { parseRevote } = require('./parse-stage2');
30
+ const runState = require('./run-state');
31
+ const { emitStageStarted } = require('../observe/events');
32
+ const { isAbortExit } = require('./run-launch');
33
+ // v4.8 PR3 Task 6: seat binding. ./seats requires NOTHING, so taking
34
+ // sanitizeName straight from it (rather than run-launch's re-export) adds
35
+ // zero cycle risk to this leaf — the same call run-stage2.js:30 makes.
36
+ const { sanitizeName } = require('./seats');
37
+ // v4.8 SI-27: the shared roster-padding core. ./stage1-bind requires only
38
+ // ./seats, so this leaf stays cycle-free (see the module docblock's cycle-class
39
+ // paragraph above — named, not line-numbered, so it cannot rot).
40
+ const { bindPaddedWave } = require('./stage1-bind');
41
+
42
+ /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
43
+ function legOpts(ctx, waveId) {
44
+ return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
45
+ noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate,
46
+ // v4.3 Task 3 (spec §7.2): attribution ids for every defense/re-vote leg.
47
+ councilRunId: ctx.o.runId, councilName: ctx.o.councilName,
48
+ tag: ctx.o.tag }; // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
49
+ }
50
+
51
+ /**
52
+ * v4.7 D2/E4: normalize a raw (possibly leg-absent) leg into the shape
53
+ * debateRunStatsRows' superseded/repair lists expect. Same never-invent-a-waveId
54
+ * discipline as buildRunStatsEntry (run-assemble.js) — only spread `waveId` when
55
+ * the leg genuinely carries one — but keyed on an explicit `model` (the raiser or
56
+ * judge identity), since a leg-absent attempt has no `.model` of its own to read.
57
+ * Threads resolvedModel (the raw leg's .model, the executable id) emit-only-when-set — v4.7 GOA-7 D8.
58
+ */
59
+ function legRow(model, leg, conformance) {
60
+ return leg
61
+ ? { model, status: leg.status, durationMs: typeof leg.durationMs === 'number' ? leg.durationMs : null,
62
+ usage: leg.usage || null, conformance, summary: leg.summary || '',
63
+ ...(leg.waveId ? { waveId: leg.waveId } : {}),
64
+ ...(leg.model ? { resolvedModel: leg.model } : {}) }
65
+ : { model, status: 'error', durationMs: null, usage: null, conformance, summary: '' };
66
+ }
67
+
68
+ /** The wave's join key for one leg: the bound seat's id, else the bare alias. */
69
+ function seatKey(seat, alias) { return seat ? seat.id : alias; }
70
+
71
+ /**
72
+ * T5.1 (owner ruling R8), narrowed by T5.5: announce a re-vote leg whose key
73
+ * this wave cannot account for — its key names none of the judges this wave
74
+ * actually launched. ⚠️ This read "it NEITHER bound to any roster slot (a real
75
+ * seat or a §3.4 placeholder) NOR names one of the judges…" until T5.5 deleted
76
+ * the `boundLegs` arm of the guard below. Binding is no longer part of the
77
+ * condition, and must not be re-added: seats.js :: bindSeats binds on
78
+ * `leg.legId || leg.taskId` with NO alias check, so a leg bound that way can
79
+ * still carry a name this wave never asked for — which is exactly the shape
80
+ * that kept inventing a phantom adjudication row. The
81
+ * leg itself is unaffected — it still gets its runStats row, its
82
+ * revote-<name>.md and its conformance — only its parsed votes are
83
+ * withheld, so `revoteByJudge` never carries this key at all and
84
+ * applyDebate never has to decide whether it belongs to an existing row or
85
+ * is new (that fail-open push in debate.js :: applyDebate is untouched for a
86
+ * key this wave DOES account for).
87
+ *
88
+ * Field shape follows stage1-bind.js:53 :: orphanLegNote's
89
+ * channel/what/why/effect/data — with ONE deliberate divergence: `data`
90
+ * carries no `seat` field. orphanLegNote's `data.seat` (confusingly named —
91
+ * it holds the ALIAS, not a seat object) is exactly what
92
+ * seat-space.js :: orphanExonerations reads to attribute a note to an
93
+ * alias. This note's `waveId` is always `<runId>-rv`, never `<runId>-s2`, so
94
+ * if it carried that field too it would enter that function's alias map and
95
+ * CLEAR — not extend — that alias's already-proven Stage-2 exonerations,
96
+ * via the non-`-s2` branch of the intersection there: a re-vote-stage
97
+ * anomaly wrongly invalidating a Stage-2 review-authorship proof. Do not add
98
+ * `seat` here to "complete" the parity with orphanLegNote; it is withheld
99
+ * on purpose.
100
+ */
101
+ function reVoteUnboundNote(waveId, judge, key, leg) {
102
+ const legId = (leg && (leg.legId || leg.taskId)) || 'unidentified';
103
+ // `|| 'unknown'` mirrors stage1-bind.js:55's alias fallback, and for the same reason: the
104
+ // caller derives `judge` as `leg.modelInput || leg.model`, so a leg carrying NEITHER makes the
105
+ // record read "… 'undefined'" — a bug in the announcer rather than a fact about the leg.
106
+ // ⚠️ BOTH need it. `key` is `seatKey(seat, judge)`, which RETURNS that same `judge` whenever the
107
+ // leg bound to no real seat — i.e. in every refusal reachable through runDebate — so an undefined
108
+ // `judge` takes `key` with it. T5.5 interpolated `key` raw for one commit and measurably rendered
109
+ // "its join key 'undefined'" AND dropped `data.key` from the JSON. Pinned by
110
+ // run-debate.test.js's "NEITHER modelInput NOR model" test; named mutant KEYRAW.
111
+ const alias = judge || 'unknown';
112
+ const joinKey = key || 'unknown';
113
+ return {
114
+ channel: 'seat-unbound',
115
+ // ⚠️ All three strings below now say the ONE thing the guard tests: the key names no judge
116
+ // this wave launched. Binding is irrelevant — this function's docblock says why.
117
+ // ⚠️ `what` said "matches no seat on that wave's roster" for three rounds AFTER that stopped
118
+ // being the condition: a leg taskId-bound to a §3.4 placeholder DOES match a roster slot and is
119
+ // exactly the leg now refused, so `what` contradicted the `why` three lines under it. A paid
120
+ // council caught it (round 2). ⚠️ stage1-bind.js :: orphanLegNote KEEPS that wording and MUST:
121
+ // a Stage-1 orphan really does match no roster slot. Same sentence, true there, false here.
122
+ // ⚠️ `effect` said "the JUDGE's provisional verdict stands" until round 2 — the very
123
+ // presumption a refusal denies, since a refused key names no judge of this wave.
124
+ // ⚠️ `why` carried "(judge alias '${alias}')" for one commit; dropped, measured — `key ===
125
+ // judge` in every refusal runDebate can produce, so it printed the same string twice
126
+ // (BACKLOG.md holds the measurement). `data` keeps orphanLegNote's field names, `judge`
127
+ // included: it is the leg's own CLAIM, and renaming a machine-readable field is a compat break.
128
+ what: `re-vote leg ${legId} in wave ${waveId} could not be attributed to a judge on that wave`,
129
+ why: `its join key '${joinKey}' names none of the judges this wave launched`,
130
+ effect: 'the re-vote was NOT applied; the provisional verdict stands',
131
+ data: { waveId, legId, judge: alias, key: joinKey },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The re-vote mini-wave (spec §5.1).
137
+ *
138
+ * v4.8 PR3 Task 6 — the parallel-array discipline this function now runs on:
139
+ * `judgeKeys` are SEAT ids (disputingJudges' output) and `judgeSeats` the
140
+ * matching seat objects, both in launch order; `aliasOf` projects a key back to
141
+ * the routable bench alias. On every iteration of the leg loop the seat key and
142
+ * the alias are **two different values**: the key is what `byJudge` is keyed on
143
+ * (so `applyDebate` can join it against `(a.seat || a.judge)`), while the alias
144
+ * is what every launcher argument and every runStats `model` carries. That is
145
+ * not an inconsistency — a seat id is not a routable model name.
146
+ *
147
+ * @param {object} ctx run.js's {o, launchers, addWave, overBudget, degrade, scratchDir}
148
+ * @param {Array<string>} judgeKeys seat ids, in launch order
149
+ * @param {Array<object>} bundleFindings defended/amended findings
150
+ * @param {Array<?object>} judgeSeats seat objects positionally bound to judgeKeys
151
+ * @param {function(string): string} aliasOf seat id → bench alias
152
+ */
153
+ async function runRevoteWave(ctx, judgeKeys, bundleFindings, judgeSeats, aliasOf) {
154
+ const bundle = dbrief.buildRevoteBundle({ findings: bundleFindings, date: ctx.o.date });
155
+ // spec §5.1 names `revote-bundle.md` a run-dir artifact: the shared re-vote prompt goes to
156
+ // disk exactly like Stage 2's bundle-stage2.md, so the round's model-facing input is
157
+ // auditable alongside briefing-stage1.md and chair-packet.md.
158
+ fs.writeFileSync(path.join(ctx.o.runDir, 'revote-bundle.md'), bundle, { mode: 0o600 });
159
+ const waveId = `${ctx.o.runId}-rv`;
160
+ const expectedIds = bundleFindings.map(f => f.id);
161
+ // run-debate — not run.js — owns this stage's `running` checkpoint AND its abort-cascade
162
+ // id: only this function knows whether the wave actually launched (it is skipped when
163
+ // nothing was defended/amended, or the cost ceiling hit).
164
+ runState.updateStage(ctx.o.runDir, 'debate-revote',
165
+ { status: 'running', startedAt: new Date().toISOString(), project: ctx.scratchDir, waveId });
166
+ emitStageStarted(ctx.o.runDir, ctx.o.runId, 'debate-revote', waveId, ctx.o.follow);
167
+ runState.appendStageWave(ctx.o.runDir, 'debate-revote', waveId);
168
+ // ⚠️ The launcher takes ALIASES. A seat id here is a non-routable model name
169
+ // on a real paid wave, not a test failure.
170
+ const res = await ctx.launchers.launchWave({
171
+ ...legOpts(ctx, waveId), models: judgeKeys.map(aliasOf), prompt: bundle });
172
+ ctx.addWave(res.wave);
173
+ if (isAbortExit(res.exitCode)) { return { aborted: res.exitCode }; }
174
+ const byJudge = {}, legs = [];
175
+ // v4.7 D2/E4: mirrors runDefenseSolo's supersededLeg/repairLeg — one list each,
176
+ // accumulated across every judge in this wave (most judges contribute neither).
177
+ const supersededLegs = [], repairLegs = [];
178
+ const rawLegs = (res.wave && res.wave.legs) || [];
179
+ // §3.4's roster-padding pattern now lives in `stage1-bind.js ::
180
+ // bindPaddedWave` (v4.8 SI-27) — why the roster is padded rather than
181
+ // filtered, and why placeholders are tracked by IDENTITY rather than an
182
+ // id-name prefix test, are in that function's docblock. This site has NO
183
+ // tail: no orphan push, no degrade note, so it destructures `seatOf` alone.
184
+ const { seatOf } = bindPaddedWave(waveId, judgeSeats || [], i => aliasOf(judgeKeys[i]), rawLegs);
185
+ for (const leg of rawLegs) {
186
+ // The council ALIAS, not the resolved executable id — runStats rows join
187
+ // meta.models by exact string (run-assemble.js's buildRunStatsEntry).
188
+ const judge = leg.modelInput || leg.model;
189
+ const seat = seatOf.get(leg) || null;
190
+ const key = seatKey(seat, judge);
191
+ const alive = leg.status === 'complete' && leg.summary;
192
+ let outLeg = leg; // the leg actually recorded (post-repair when there is one)
193
+ let parsed = alive ? parseRevote(leg.summary, expectedIds)
194
+ : { ok: false, byId: {}, errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
195
+ let conformance = alive ? 'clean' : 'unstructured';
196
+ if (alive && !parsed.ok) {
197
+ // One repair, solo, to that judge. The id is built from the SEAT key so
198
+ // two twins never share one repair id (and one never overwrites the
199
+ // other's run-state entry). ⚠️ The trailing `r` is load-bearing: it is what
200
+ // stops bindSeats' `/^(.*)-(\d+)$/` matching a repair id whose judge alias
201
+ // is a bare number (`r1-rv-2r` does not match; `r1-rv-2` would). Dropping
202
+ // it re-arms a collision. sanitizeName also fixes the pre-existing slash
203
+ // bug (D4) — `r1-rv-openrouter/deepseek/deepseek-chatr` stops nesting
204
+ // three directory levels — and is a no-op for every plain alias.
205
+ const repairId = `${waveId}-${sanitizeName(key)}r`;
206
+ runState.appendStageWave(ctx.o.runDir, 'debate-revote', repairId);
207
+ const r2 = await ctx.launchers.launchSolo({ ...legOpts(ctx, repairId), model: judge,
208
+ // ⚠️ LC-12: ditto — the re-vote output being repaired rides with its errors.
209
+ prompt: dbrief.buildRevoteRepairPrompt({ errors: parsed.errors, revote: leg.summary }) });
210
+ ctx.addWave(r2.wave);
211
+ if (isAbortExit(r2.exitCode)) { return { aborted: r2.exitCode }; }
212
+ const leg2 = r2.leg && r2.leg.status === 'complete' ? r2.leg : null;
213
+ parsed = leg2 ? parseRevote(leg2.summary, expectedIds) : parsed;
214
+ conformance = parsed.ok ? 'repaired' : 'unstructured';
215
+ // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
216
+ // revote-<model>.md and the runStats row keep the PRE-repair output.
217
+ if (leg2) { supersededLegs.push(legRow(judge, leg, 'unstructured')); outLeg = leg2; }
218
+ else { repairLegs.push(legRow(judge, r2.leg, 'unstructured')); }
219
+ }
220
+ // ⚠️ Two DIFFERENT values on the same iteration: `byJudge`'s key is the SEAT
221
+ // (applyDebate joins it against `(a.seat || a.judge)`), while the leg's
222
+ // `model` is the ALIAS — this literal becomes a debateRunStatsRows row, and
223
+ // R3-1 promises every runStats `model` is alias-valued on every bench.
224
+ // `seat` rides along for materializeDebate's filename only; debateRunStatsRows'
225
+ // `mk` copies an explicit field list and never picks it up.
226
+ //
227
+ // T5.1 (owner ruling R8), narrowed by T5.5: publish IFF the key names one of
228
+ // the judges THIS WAVE actually launched. `judgeSeats` is positionally bound
229
+ // to `judgeKeys` (this function's own docblock) and run-debate.js builds it
230
+ // from an id-keyed table — `judgeKeys.map(k => seatById.get(k) || null)` — so
231
+ // every REAL seat id that can reach `key` here is already a `judgeKeys`
232
+ // entry. Two further shapes are deliberately admitted: a
233
+ // unique-alias bench, where `seatKey(null, 'qwen') === 'qwen'` IS that seat's
234
+ // own judgeKey; and a §3.4 roster hole (a Stage-2-orphaned judge, padded with
235
+ // a placeholder here) whose -rv leg is ALSO unbindable — its bare-alias key is
236
+ // still in `judgeKeys`, and the provisional row for that same orphaned judge
237
+ // is ALSO keyed on the bare alias, so refusing it would discard a re-vote the
238
+ // join already lands correctly (measured: 2 adjudications in, 2 out, the
239
+ // seat-less row's verdict replaced, no phantom row). NOT `seat === null`
240
+ // (§0.5) — a real-seat bind is unaffected either way, but that predicate
241
+ // wrongly refuses both shapes just described. Only a leg whose key names no
242
+ // judge this wave launched is the unnameable case R8 asks to refuse.
243
+ //
244
+ // ⚠️ T5.5 DELETED a second arm, `boundLegs.has(leg) ||`. Binding is not
245
+ // enough: seats.js :: bindSeats matches `leg.legId || leg.taskId` to a roster
246
+ // SLOT with NO alias check, so a leg stamped into a §3.4 placeholder's slot
247
+ // while carrying a foreign alias BOUND (arm 1 true) and still keyed on that
248
+ // foreign alias (arm 2 false) — the key was published, no note was emitted,
249
+ // and applyDebate's fail-open push invented a phantom adjudication row while
250
+ // the hole's own seat-less row kept its stale dispute. That is the SI-10 shape
251
+ // this guard exists to close, surviving through that arm. Measured across the
252
+ // deletion in run-debate.test.js's T5.5 block: 3 A1 rows out of 2 in and zero
253
+ // notes before, 2 out and one `seat-unbound` note after. Do not re-add the arm
254
+ // as "defensive redundancy" — named mutant BOUNDREADD in that block is exactly
255
+ // that re-addition, and it reds.
256
+ if (judgeKeys.includes(key)) {
257
+ byJudge[key] = parsed.byId;
258
+ } else {
259
+ ctx.degrade.note(reVoteUnboundNote(waveId, judge, key, leg));
260
+ }
261
+ legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage,
262
+ conformance, summary: outLeg.summary || '', waveId: outLeg.waveId, seat,
263
+ ...(outLeg.model ? { resolvedModel: outLeg.model } : {}) });
264
+ }
265
+ return { byJudge, legs, supersededLegs, repairLegs };
266
+ }
267
+
268
+ module.exports = { legOpts, legRow, runRevoteWave };