amicus 4.7.1 → 4.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +988 -1
  3. package/README.md +5 -4
  4. package/docs/CITATIONS.md +122 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +179 -32
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/troubleshooting.md +3 -3
  12. package/docs/usage.md +2 -2
  13. package/electron/ipc-setup.js +18 -2
  14. package/electron/main.js +46 -3
  15. package/electron/setup-ui-aliases.js +2 -2
  16. package/electron/setup-ui-model.js +99 -9
  17. package/electron/setup-ui-styles.js +22 -0
  18. package/electron/setup-ui.js +231 -29
  19. package/electron/workspace-ui/index.html +9 -0
  20. package/electron/workspace-ui/live-dead-seats.js +228 -0
  21. package/electron/workspace-ui/live-model.js +10 -236
  22. package/electron/workspace-ui/live-seats.js +126 -0
  23. package/electron/workspace-ui/workspace-app.js +6 -41
  24. package/electron/workspace-ui/workspace-banners.js +95 -0
  25. package/electron/workspace-ui/workspace-lazy.js +55 -12
  26. package/electron/workspace-ui/workspace-matrix.js +2 -2
  27. package/electron/workspace-ui/workspace-panels.js +42 -10
  28. package/electron/workspace-ui/workspace-render.js +2 -2
  29. package/electron/workspace-ui/workspace-seats.js +101 -17
  30. package/package.json +4 -1
  31. package/schemas/council-run-live.schema.json +1 -0
  32. package/schemas/council-run.schema.json +19 -0
  33. package/schemas/council-tally.schema.json +34 -2
  34. package/schemas/council-verdict.schema.json +15 -0
  35. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  36. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  37. package/skills/second-opinion/SKILL.md +25 -11
  38. package/src/cli-handlers-doctor.js +18 -14
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/parse-stage2.js +1 -1
  47. package/src/council/peer-split.js +196 -0
  48. package/src/council/report-html.js +12 -5
  49. package/src/council/report-md.js +146 -0
  50. package/src/council/report.js +188 -112
  51. package/src/council/run-assemble.js +100 -79
  52. package/src/council/run-chair.js +17 -1
  53. package/src/council/run-debate-revote.js +268 -0
  54. package/src/council/run-debate.js +92 -102
  55. package/src/council/run-finish.js +70 -0
  56. package/src/council/run-launch.js +45 -18
  57. package/src/council/run-retry-group.js +266 -0
  58. package/src/council/run-retry-keys.js +74 -0
  59. package/src/council/run-retry-launch.js +55 -0
  60. package/src/council/run-retry-notes.js +65 -13
  61. package/src/council/run-retry.js +166 -161
  62. package/src/council/run-stage1-launch.js +27 -10
  63. package/src/council/run-stage1-rows.js +220 -0
  64. package/src/council/run-stage1-superseded.js +156 -0
  65. package/src/council/run-stage2.js +65 -5
  66. package/src/council/run-stages.js +72 -69
  67. package/src/council/run-state.js +1 -1
  68. package/src/council/run-stats-entry.js +71 -0
  69. package/src/council/run-verdict-files.js +52 -0
  70. package/src/council/run.js +42 -52
  71. package/src/council/seats.js +262 -0
  72. package/src/council/stage1-bind.js +142 -0
  73. package/src/council/street-cred.js +258 -0
  74. package/src/council/tally.js +100 -49
  75. package/src/council/verdict.js +49 -1
  76. package/src/headless.js +20 -6
  77. package/src/mcp-tools.js +71 -1
  78. package/src/observe/council-legs.js +7 -1
  79. package/src/observe/live-doc.js +3 -3
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-wave-io.js +26 -1
  82. package/src/sidecar/fanout.js +4 -10
  83. package/src/sidecar/leg-ids.js +19 -0
  84. package/src/sidecar/models-probe.js +7 -4
  85. package/src/sidecar/reopen-spend.js +1 -1
  86. package/src/sidecar/setup.js +137 -2
  87. package/src/utils/alias-audit.js +81 -3
  88. package/src/utils/config.js +109 -11
  89. package/src/utils/curated-models.js +17 -2
  90. package/src/utils/degrade.js +5 -0
  91. package/src/utils/doctor-alias-check.js +152 -0
  92. package/src/utils/model-canonicalization.js +64 -0
  93. package/src/utils/model-shortlist.js +100 -0
  94. package/src/utils/no-output-backstop.js +1 -1
  95. package/src/utils/provider-default-picker.js +93 -45
  96. package/src/utils/provider-default-prompt.js +1 -1
  97. package/src/utils/quick-picks.js +2 -2
  98. package/src/utils/remediation-hints.js +37 -0
  99. package/src/utils/session-index-prune.js +297 -0
  100. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  101. package/src/workspace/artifact-guard.js +8 -114
  102. package/src/workspace/artifact-names.js +222 -0
  103. package/src/workspace/fold-format.js +9 -6
  104. package/src/workspace/live-normalize.js +6 -2
  105. package/src/workspace/matrix-model.js +141 -19
  106. package/src/workspace/run-detail.js +30 -4
  107. package/src/workspace/seat-space.js +143 -0
@@ -0,0 +1,272 @@
1
+ // src/council/briefings-chair.js
2
+ 'use strict';
3
+ // Chair briefing surface: packet, repair prompt, verdict scale, task
4
+ // templates, no-tools preamble. Moved verbatim from briefings-stage2.js
5
+ // (chunks :16-20 and :154-279 at e8406db; v4.8 PR0 size-gate split, zero
6
+ // behavior). dateLine lives HERE (not required back from stage2) because
7
+ // stage2 requires this module — a back-require would be a cycle that
8
+ // resolves dateLine to undefined at load. briefings-stage2.js re-exports
9
+ // every public name below so existing import paths stay stable.
10
+ //
11
+ // ./seats is the ONLY require here, and it is cycle-free by construction: that
12
+ // module deliberately requires nothing (seats.js header), so the load-order
13
+ // hazard above does not apply to it. Do NOT require ./briefings-stage2.
14
+ const { displayName } = require('./seats');
15
+
16
+ const CHAIR_NO_TOOLS_PREAMBLE =
17
+ 'Do NOT use any tools or read any files; everything is in this message; ' +
18
+ 'begin immediately with the verdict.';
19
+
20
+ const CHAIR_VERDICT_VALUES = ['Ship it', 'Fix these first', 'Fundamental rethink'];
21
+
22
+ /** Shared date line (spec §4.3) — prepended to every model-facing briefing. */
23
+ function dateLine(date) { return `Today's date is ${date}.`; }
24
+
25
+ /** Verdict-scale addendum (SEAT-BRIEFS.md § Chair verdict-scale addendum; always on headless). */
26
+ const VERDICT_SCALE_ADDENDUM = [
27
+ 'After your synthesis, add two closing sections:',
28
+ '',
29
+ '1. HARD QUESTIONS — three to five questions the material\'s author has probably not',
30
+ ' asked themselves, chosen so that an unanswerable question reveals a structural gap',
31
+ ' (not gotchas — questions whose answers should exist).',
32
+ '2. A final line, alone on the last line, containing ONLY the phrase — no rationale, no',
33
+ ' dash, no trailing text of any kind — exactly one of:',
34
+ '',
35
+ ' VERDICT: Ship it',
36
+ ' VERDICT: Fix these first',
37
+ ' VERDICT: Fundamental rethink',
38
+ '',
39
+ ' Pick one. "Ship it" = solid, nothing blocking. "Fix these first" = specific gaps',
40
+ ' must be resolved first. "Fundamental rethink" = structural problems that cannot be',
41
+ ' patched. Name the gaps or the structural problems in the synthesis ABOVE, not on the',
42
+ ' VERDICT line itself — that line carries the phrase and nothing else.',
43
+ ].join('\n');
44
+
45
+ /** The chair's opening instruction when there is something to weigh (the ordinary case). */
46
+ const CHAIR_TASK =
47
+ 'You are the council chair. Write the synthesized verdict across the reviews, ' +
48
+ 'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
49
+ 'peer-validated standing (rank position and adjudication pattern), distinguish ' +
50
+ 'findings the bench broadly endorsed from contested or singleton claims, and ' +
51
+ 'arrive at an overall assessment of the material.';
52
+
53
+ /**
54
+ * The chair's opening instruction when the bench raised nothing at all (LC-10
55
+ * fast-follow, review minor M2).
56
+ *
57
+ * Task 3 fixed this by keeping CHAIR_TASK unchanged and appending a correcting note
58
+ * — asymmetric with buildJudgeBundle, which swaps JUDGE_TASK_B for
59
+ * JUDGE_TASK_B_NO_FINDINGS outright. The un-swapped CHAIR_TASK asks the chair to
60
+ * "distinguish findings the bench broadly endorsed from contested or singleton
61
+ * claims" over an empty tier table and two bare section headings — an instruction
62
+ * whose only satisfiable reading is to invent material — and the chair reads that
63
+ * unfollowable directive FIRST, the correction second. This constant replaces
64
+ * CHAIR_TASK entirely on a clean bench instead: it never asks for a distinction
65
+ * that cannot exist, and the clean-bench framing (still stated, not just implied)
66
+ * is now the only instruction the chair sees, not an amendment to a bad one.
67
+ */
68
+ const CHAIR_TASK_NO_FINDINGS =
69
+ 'You are the council chair. Write the synthesized verdict across the reviews and ' +
70
+ 'rankings below. Weigh each reviewer\'s standing by rank position, and arrive at ' +
71
+ 'an overall assessment of the material. NOTE: this bench raised NO findings. ' +
72
+ 'Every reviewer read the material and reported nothing to fix, which is a valid ' +
73
+ 'outcome — not a failed run. Synthesize on that basis: say what the reviews ' +
74
+ 'actually establish and where the bench\'s agreement is thin, and do not ' +
75
+ 'manufacture concerns to fill the sections below.';
76
+
77
+ /** A section body, or an explicit reason it is empty — never a heading over nothing. */
78
+ function orNone(text, none) {
79
+ return (typeof text === 'string' && text.trim()) ? text : none;
80
+ }
81
+
82
+ /**
83
+ * Site (3)'s per-slot zip (v4.8 SI-25, ruling R25-3). `order` and `orderSeats`
84
+ * are PARALLEL per-slot arrays minted together by
85
+ * `anonymize.js :: rankingToOrder`, and a slot is either a scalar or an array
86
+ * (a tie group) — hence the two arms.
87
+ *
88
+ * EVERY read is a fallback, because `orderSeats` is a PARITY SHAPE, not a
89
+ * presence: its `seatOne` returns `(seatMap && seatMap[label]) || null`, and
90
+ * `run-assemble.js :: buildTallyInput` ships the array whenever ANY entry is
91
+ * truthy — so a MIXED array of seat ids and `null`s is a normal shipping shape.
92
+ * No `null` may reach the rendered JSON: this is the one artifact a paid chair
93
+ * reads as authoritative. An absent `orderSeats` returns `order` untouched
94
+ * (spec §4.2's byte-identity promise). ⚠️ That promise is UNCONDITIONAL as of
95
+ * the SHAPESWAP fix — it holds even when the two arrays DISAGREE on a slot's
96
+ * structure (scalar vs array), which no producer here can emit but which two
97
+ * independent reviewers reached for. A disagreeing slot falls back to `order`'s
98
+ * own value and NEVER changes that slot's shape.
99
+ *
100
+ * ⚠️ The SHORT-array arm is DEFENSIVE, and this docblock does not claim
101
+ * otherwise: `rankingToOrder` mints both arrays from one `slots.map`, so no
102
+ * producer in this tree can emit an `orderSeats` shorter than its `order`
103
+ * (measured — 0 mismatches over 24 ranking × seatMap shapes). The arm and its
104
+ * pin guard hand-assembled input, which is why they stay.
105
+ *
106
+ * ⚠️ `||`, deliberately, NOT `??`. `anonymize.js :: rankingToOrder`'s `seatOne`
107
+ * returns `(seatMap && seatMap[label]) || null`, so every entry this array can
108
+ * hold is already truthy-or-null and the two operators are behaviourally
109
+ * identical on it. Where they differ — a falsy-but-present id — `||` falls back
110
+ * to the alias and `??` would render the empty string into the packet, so `||`
111
+ * is never worse and sometimes safer. It is also this spine's convention
112
+ * (`seat || alias` at every other site). PR #189's council raised the swap as
113
+ * A1 (solid); declined on that measurement, and recorded here so the next
114
+ * reader does not re-propose it as a tidy-up.
115
+ *
116
+ * Two named mutants guard the two arms — NULLLEAK the scalar fallback, FLATTIE
117
+ * the tie arm — with their measured red sets in
118
+ * tests/council/chair-packet-seat-mutants.js :: NULLLEAK. Re-run them, never
119
+ * renumber, if this expression or its pins change.
120
+ */
121
+ function seatKeyedOrder(order, orderSeats) {
122
+ if (!Array.isArray(order) || !Array.isArray(orderSeats)) { return order; }
123
+ return order.map((slot, i) => {
124
+ const seats = orderSeats[i];
125
+ return Array.isArray(slot)
126
+ ? slot.map((alias, k) => (Array.isArray(seats) ? seats[k] : null) || alias)
127
+ // ⚠️ `Array.isArray(seats)` here, NOT just `seats || slot`: a scalar `order`
128
+ // slot against an ARRAY `orderSeats` slot returned the seats array RAW, which
129
+ // both changed the slot's shape and could render `[null,null]` — breaking the
130
+ // no-null promise above for the one input class this arm exists to defend.
131
+ // Named mutant SHAPESWAP (drop this guard) in
132
+ // tests/council/chair-packet-seat-mutants.js :: SHAPESWAP.
133
+ : (Array.isArray(seats) ? slot : (seats || slot));
134
+ });
135
+ }
136
+
137
+ /**
138
+ * De-anonymized chair packet (spec §5/§6: the chair sees identities).
139
+ *
140
+ * ⚠️ `seat` is an OBJECT on `reviews[]` and a STRING on `rankings[]` and
141
+ * `adjudications[]`. Deliberate, and declared as such in the @param below.
142
+ * Reviews carry the object because `seats.js :: displayName` is the naming seam
143
+ * (v4.8 SI-25 / R25-4); the other two carry `j.seat.id` because
144
+ * `run-assemble.js :: buildTallyInput` has emitted a string there since PR4c and
145
+ * changing it would move a shipped tally schema. These are three arrays each
146
+ * with one consistently-typed field, not one polymorphic field. PR #189's
147
+ * council raised it as D1 (major, CONTESTED a1/d1/n1); declined on that reading.
148
+ * @param {{reviews: Array<{model: string, text: string, seat?: ?object}>,
149
+ * rankings: Array<{judge: string, seat?: ?string, order: Array<string|string[]>,
150
+ * orderSeats?: ?Array<?string|Array<?string>>}>,
151
+ * adjudications: Array<{findingId: string, judge: string, seat?: ?string, verdict: string}>,
152
+ * tierCounts: object}} args
153
+ * `rankings` and `adjudications` may both be empty — an all-clean bench (LC-10)
154
+ * has nothing to adjudicate, and a Stage 2 whose judges all died has nothing to
155
+ * rank. Each empty section says WHICH of those it is rather than rendering blank.
156
+ */
157
+ function buildChairPacket({ reviews, rankings, adjudications, tierCounts, date, findings }) {
158
+ // ⚠️ v4.8 SI-25: all three rendering sites below are SEAT-KEYED with an ALIAS
159
+ // FALLBACK. Alias-keyed, a twin bench handed the chair "tier counts:
160
+ // {Confirmed: 1}" beside two identical `A1 — deepseek:` lines, with nothing in
161
+ // the packet able to reconcile them; PR4c seat-keyed the report and the
162
+ // Workspace matrix but not this packet, so the human-facing artifact and the
163
+ // model-facing one disagreed.
164
+ // ⚠️ THIS PACKET IS PROSE, NEVER A LAUNCH ARGUMENT — that boundary is what
165
+ // makes seat ids safe here. A seat id in a model-carrying LAUNCH argument is a
166
+ // non-routable model name and a real paid failure, which is why `order`,
167
+ // `orderSeats`, `tallyInput`, verdict.json, the report and every launcher
168
+ // option stay alias-valued and untouched (run-debate.test.js's parity pin
169
+ // guards that boundary). Nothing routes on this artifact.
170
+ // The fallbacks are load-bearing, not defensive — named mutants ALIASBACK and
171
+ // SEATONLY, with their measured red sets, in
172
+ // tests/council/chair-packet-seat-mutants.js :: ALIASBACK. On a unique-alias
173
+ // bench the seat channel is ABSENT at every site (spec §4.2's
174
+ // emit-when-DIFFERENT rule), so the rendering is byte-identical there by
175
+ // construction — and the review projection that feeds site (1) applies that
176
+ // same rule for a reason its own comment gives.
177
+ const reviewBlocks = reviews
178
+ .map(r => `--- Review by ${displayName(r.seat) || r.model} ---\n${r.text}`).join('\n\n');
179
+ const rankingLines = (rankings || [])
180
+ .map(r => `${r.seat || r.judge}: ${JSON.stringify(seatKeyedOrder(r.order, r.orderSeats))}`)
181
+ .join('\n');
182
+ const adjLines = (adjudications || [])
183
+ .map(a => `${a.findingId} — ${a.seat || a.judge}: ${a.verdict}`)
184
+ .join('\n');
185
+ // ⚠️ v4.8 PR5a T7 (R5-3): surface R8. tally.js has stamped sameModelCorroboration since
186
+ // PR4c and it reached verdict.json — and stopped. Spec §4.6 says the chair packet is
187
+ // REQUIRED to surface it; measured, nothing did. A Confirmed reached only via the
188
+ // raiser's own twin read to the chair exactly like independent corroboration, which is
189
+ // the overstatement R8 was chosen over model-exact exclusion to prevent.
190
+ // Emit-when-present, so a bench that raised none is byte-identical.
191
+ const corroborated = (findings || [])
192
+ // ⚠️ `&& f.id` (council-3 C3, as corrected by measurement): the reported symptom —
193
+ // 'undefined' in the prose — does NOT occur, because Array#join renders undefined as ''.
194
+ // What DID render is worse in kind: "…corroborated only by a same-model seat: ." — the R8
195
+ // caveat firing while naming nobody, in the one artifact a paid chair reads as
196
+ // authoritative. Unnameable findings are dropped, and the emit-when-present guard below
197
+ // then suppresses the section entirely rather than showing an empty list.
198
+ .filter(f => f && f.sameModelCorroboration && f.id)
199
+ .map(f => f.id);
200
+ // Every finding lands in exactly one tier (tally.js countTiers), so the tier
201
+ // counts sum to the record's finding count — which is how an all-clean bench is
202
+ // told apart from a bench whose judges simply never voted.
203
+ const raisedCount = Object.values(tierCounts || {})
204
+ .reduce((s, n) => s + (typeof n === 'number' ? n : 0), 0);
205
+ const tiers = JSON.stringify(tierCounts);
206
+ const parts = [CHAIR_NO_TOOLS_PREAMBLE];
207
+ if (date) { parts.push(dateLine(date)); }
208
+ parts.push(raisedCount === 0 ? CHAIR_TASK_NO_FINDINGS : CHAIR_TASK);
209
+ parts.push(
210
+ `Deterministic tier counts (peers-only cascade): ${tiers}`,
211
+ '--- STAGE-1 REVIEWS (de-anonymized) ---',
212
+ reviewBlocks,
213
+ '--- PEER RANKINGS (judge: order, best first) ---',
214
+ orNone(rankingLines, '(none — no judge produced a usable ranking)'),
215
+ '--- PER-FINDING ADJUDICATIONS ---',
216
+ orNone(adjLines, raisedCount === 0
217
+ ? '(none — the bench raised no findings, so there was nothing to adjudicate)'
218
+ : '(none — no judge produced a usable adjudication)'),
219
+ );
220
+ // Placed AFTER the adjudications it qualifies and BEFORE the verdict scale, so the chair
221
+ // reads the caveat while the votes are still in view rather than after being told how to
222
+ // score them.
223
+ if (corroborated.length) {
224
+ parts.push(
225
+ '--- SAME-MODEL CORROBORATION (R8) ---',
226
+ `These findings were agreed only by a seat running the SAME model as the raiser: ${corroborated.join(', ')}.`
227
+ + ' Their tier reflects concurrence between two generations of one model, not independent support.'
228
+ + ' Weigh them accordingly.',
229
+ );
230
+ }
231
+ parts.push(VERDICT_SCALE_ADDENDUM);
232
+ return parts.join('\n\n');
233
+ }
234
+
235
+ /**
236
+ * One-shot chair repair: the VERDICT line was missing (spec §5 chair contract).
237
+ *
238
+ * ⚠️ LC-12: this builder took NO arguments at all, so the repair solo — a fresh
239
+ * session — was asked for a verdict on a synthesis it could not see. The chair's
240
+ * synthesis WAS received; it is the verdict line that is missing. Handing back the
241
+ * synthesis lets the chair pick the verdict its own prose supports instead of
242
+ * re-deriving one from nothing.
243
+ * @param {{synthesis?: string}} [args]
244
+ */
245
+ function buildChairRepairPrompt({ synthesis } = {}) {
246
+ const text = typeof synthesis === 'string' ? synthesis.trim() : '';
247
+ const prior = text
248
+ ? ['--- YOUR SYNTHESIS (verbatim — verdict on THIS) ---', text,
249
+ '--- END OF YOUR SYNTHESIS ---'].join('\n')
250
+ : null;
251
+ return [
252
+ 'Do NOT use any tools or read any files; everything is in this message; begin '
253
+ + 'immediately with the VERDICT line.',
254
+ ...(prior ? [prior] : []),
255
+ 'Your synthesis was received, but the final parseable line was missing. Emit ONLY '
256
+ + 'one line, exactly one of:',
257
+ 'VERDICT: Ship it',
258
+ 'VERDICT: Fix these first',
259
+ 'VERDICT: Fundamental rethink',
260
+ ].join('\n\n');
261
+ }
262
+
263
+ module.exports = {
264
+ dateLine,
265
+ CHAIR_NO_TOOLS_PREAMBLE,
266
+ CHAIR_VERDICT_VALUES,
267
+ VERDICT_SCALE_ADDENDUM,
268
+ CHAIR_TASK,
269
+ CHAIR_TASK_NO_FINDINGS,
270
+ buildChairPacket,
271
+ buildChairRepairPrompt,
272
+ };
@@ -9,19 +9,16 @@
9
9
  * seat/lens/critic information or model names (skill §5.1 / Stage-2 rule).
10
10
  */
11
11
 
12
+ // Chair surface lives in ./briefings-chair (v4.8 PR0 size-gate split).
13
+ // Every chair name is re-exported below — run-chair.js, run-assemble.js,
14
+ // briefings-debate.js and the briefings tests import them from here.
15
+ const chair = require('./briefings-chair');
16
+ const { dateLine } = chair;
17
+
12
18
  const JUDGE_NO_TOOLS_PREAMBLE =
13
19
  'Do NOT use any tools or read any files; everything is in this message; ' +
14
20
  'begin immediately with A1:';
15
21
 
16
- const CHAIR_NO_TOOLS_PREAMBLE =
17
- 'Do NOT use any tools or read any files; everything is in this message; ' +
18
- 'begin immediately with the verdict.';
19
-
20
- const CHAIR_VERDICT_VALUES = ['Ship it', 'Fix these first', 'Fundamental rethink'];
21
-
22
- /** Shared date line (spec §4.3) — prepended to every model-facing briefing. */
23
- function dateLine(date) { return `Today's date is ${date}.`; }
24
-
25
22
  /**
26
23
  * Stage-2 headless output contract (spec §5, embedded in the judge bundle).
27
24
  *
@@ -151,137 +148,12 @@ function buildJudgeRepairPrompt({ errors, judgement }) {
151
148
  ].join('\n\n');
152
149
  }
153
150
 
154
- /** Verdict-scale addendum (SEAT-BRIEFS.md § Chair verdict-scale addendum; always on headless). */
155
- const VERDICT_SCALE_ADDENDUM = [
156
- 'After your synthesis, add two closing sections:',
157
- '',
158
- '1. HARD QUESTIONS — three to five questions the material\'s author has probably not',
159
- ' asked themselves, chosen so that an unanswerable question reveals a structural gap',
160
- ' (not gotchas — questions whose answers should exist).',
161
- '2. A final line, alone on the last line, containing ONLY the phrase — no rationale, no',
162
- ' dash, no trailing text of any kind — exactly one of:',
163
- '',
164
- ' VERDICT: Ship it',
165
- ' VERDICT: Fix these first',
166
- ' VERDICT: Fundamental rethink',
167
- '',
168
- ' Pick one. "Ship it" = solid, nothing blocking. "Fix these first" = specific gaps',
169
- ' must be resolved first. "Fundamental rethink" = structural problems that cannot be',
170
- ' patched. Name the gaps or the structural problems in the synthesis ABOVE, not on the',
171
- ' VERDICT line itself — that line carries the phrase and nothing else.',
172
- ].join('\n');
173
-
174
- /** The chair's opening instruction when there is something to weigh (the ordinary case). */
175
- const CHAIR_TASK =
176
- 'You are the council chair. Write the synthesized verdict across the reviews, ' +
177
- 'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
178
- 'peer-validated standing (rank position and adjudication pattern), distinguish ' +
179
- 'findings the bench broadly endorsed from contested or singleton claims, and ' +
180
- 'arrive at an overall assessment of the material.';
181
-
182
- /**
183
- * The chair's opening instruction when the bench raised nothing at all (LC-10
184
- * fast-follow, review minor M2).
185
- *
186
- * Task 3 fixed this by keeping CHAIR_TASK unchanged and appending a correcting note
187
- * — asymmetric with buildJudgeBundle, which swaps JUDGE_TASK_B for
188
- * JUDGE_TASK_B_NO_FINDINGS outright. The un-swapped CHAIR_TASK asks the chair to
189
- * "distinguish findings the bench broadly endorsed from contested or singleton
190
- * claims" over an empty tier table and two bare section headings — an instruction
191
- * whose only satisfiable reading is to invent material — and the chair reads that
192
- * unfollowable directive FIRST, the correction second. This constant replaces
193
- * CHAIR_TASK entirely on a clean bench instead: it never asks for a distinction
194
- * that cannot exist, and the clean-bench framing (still stated, not just implied)
195
- * is now the only instruction the chair sees, not an amendment to a bad one.
196
- */
197
- const CHAIR_TASK_NO_FINDINGS =
198
- 'You are the council chair. Write the synthesized verdict across the reviews and ' +
199
- 'rankings below. Weigh each reviewer\'s standing by rank position, and arrive at ' +
200
- 'an overall assessment of the material. NOTE: this bench raised NO findings. ' +
201
- 'Every reviewer read the material and reported nothing to fix, which is a valid ' +
202
- 'outcome — not a failed run. Synthesize on that basis: say what the reviews ' +
203
- 'actually establish and where the bench\'s agreement is thin, and do not ' +
204
- 'manufacture concerns to fill the sections below.';
205
-
206
- /** A section body, or an explicit reason it is empty — never a heading over nothing. */
207
- function orNone(text, none) {
208
- return (typeof text === 'string' && text.trim()) ? text : none;
209
- }
210
-
211
- /**
212
- * De-anonymized chair packet (spec §5/§6: the chair sees identities).
213
- * @param {{reviews: Array<{model: string, text: string}>,
214
- * rankings: Array<{judge: string, order: Array<string|string[]>}>,
215
- * adjudications: Array<{findingId: string, judge: string, verdict: string}>,
216
- * tierCounts: object}} args
217
- * `rankings` and `adjudications` may both be empty — an all-clean bench (LC-10)
218
- * has nothing to adjudicate, and a Stage 2 whose judges all died has nothing to
219
- * rank. Each empty section says WHICH of those it is rather than rendering blank.
220
- */
221
- function buildChairPacket({ reviews, rankings, adjudications, tierCounts, date }) {
222
- const reviewBlocks = reviews.map(r => `--- Review by ${r.model} ---\n${r.text}`).join('\n\n');
223
- const rankingLines = (rankings || [])
224
- .map(r => `${r.judge}: ${JSON.stringify(r.order)}`)
225
- .join('\n');
226
- const adjLines = (adjudications || [])
227
- .map(a => `${a.findingId} — ${a.judge}: ${a.verdict}`)
228
- .join('\n');
229
- // Every finding lands in exactly one tier (tally.js countTiers), so the tier
230
- // counts sum to the record's finding count — which is how an all-clean bench is
231
- // told apart from a bench whose judges simply never voted.
232
- const raisedCount = Object.values(tierCounts || {})
233
- .reduce((s, n) => s + (typeof n === 'number' ? n : 0), 0);
234
- const tiers = JSON.stringify(tierCounts);
235
- const parts = [CHAIR_NO_TOOLS_PREAMBLE];
236
- if (date) { parts.push(dateLine(date)); }
237
- parts.push(raisedCount === 0 ? CHAIR_TASK_NO_FINDINGS : CHAIR_TASK);
238
- parts.push(
239
- `Deterministic tier counts (peers-only cascade): ${tiers}`,
240
- '--- STAGE-1 REVIEWS (de-anonymized) ---',
241
- reviewBlocks,
242
- '--- PEER RANKINGS (judge: order, best first) ---',
243
- orNone(rankingLines, '(none — no judge produced a usable ranking)'),
244
- '--- PER-FINDING ADJUDICATIONS ---',
245
- orNone(adjLines, raisedCount === 0
246
- ? '(none — the bench raised no findings, so there was nothing to adjudicate)'
247
- : '(none — no judge produced a usable adjudication)'),
248
- VERDICT_SCALE_ADDENDUM,
249
- );
250
- return parts.join('\n\n');
251
- }
252
-
253
- /**
254
- * One-shot chair repair: the VERDICT line was missing (spec §5 chair contract).
255
- *
256
- * ⚠️ LC-12: this builder took NO arguments at all, so the repair solo — a fresh
257
- * session — was asked for a verdict on a synthesis it could not see. The chair's
258
- * synthesis WAS received; it is the verdict line that is missing. Handing back the
259
- * synthesis lets the chair pick the verdict its own prose supports instead of
260
- * re-deriving one from nothing.
261
- * @param {{synthesis?: string}} [args]
262
- */
263
- function buildChairRepairPrompt({ synthesis } = {}) {
264
- const text = typeof synthesis === 'string' ? synthesis.trim() : '';
265
- const prior = text
266
- ? ['--- YOUR SYNTHESIS (verbatim — verdict on THIS) ---', text,
267
- '--- END OF YOUR SYNTHESIS ---'].join('\n')
268
- : null;
269
- return [
270
- 'Do NOT use any tools or read any files; everything is in this message; begin '
271
- + 'immediately with the VERDICT line.',
272
- ...(prior ? [prior] : []),
273
- 'Your synthesis was received, but the final parseable line was missing. Emit ONLY '
274
- + 'one line, exactly one of:',
275
- 'VERDICT: Ship it',
276
- 'VERDICT: Fix these first',
277
- 'VERDICT: Fundamental rethink',
278
- ].join('\n\n');
279
- }
280
-
281
151
  module.exports = {
282
- JUDGE_NO_TOOLS_PREAMBLE, CHAIR_NO_TOOLS_PREAMBLE, CHAIR_VERDICT_VALUES,
283
- JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM, dateLine,
152
+ JUDGE_NO_TOOLS_PREAMBLE, CHAIR_NO_TOOLS_PREAMBLE: chair.CHAIR_NO_TOOLS_PREAMBLE,
153
+ CHAIR_VERDICT_VALUES: chair.CHAIR_VERDICT_VALUES,
154
+ JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM: chair.VERDICT_SCALE_ADDENDUM, dateLine,
284
155
  JUDGE_TASK_B, JUDGE_TASK_B_NO_FINDINGS, NO_FINDINGS_INDEX,
285
- CHAIR_TASK, CHAIR_TASK_NO_FINDINGS,
286
- buildJudgeBundle, buildJudgeRepairPrompt, buildChairPacket, buildChairRepairPrompt,
156
+ CHAIR_TASK: chair.CHAIR_TASK, CHAIR_TASK_NO_FINDINGS: chair.CHAIR_TASK_NO_FINDINGS,
157
+ buildJudgeBundle, buildJudgeRepairPrompt,
158
+ buildChairPacket: chair.buildChairPacket, buildChairRepairPrompt: chair.buildChairRepairPrompt,
287
159
  };
@@ -11,7 +11,17 @@
11
11
  * Also holds the pure pre-debate helpers (target selection, dispute detection, re-vote bundling) moved from run-debate.js (v4.7 PR0).
12
12
  */
13
13
 
14
- const PAST_TENSE = { defend: 'defended', amend: 'amended', withdraw: 'withdrawn', 'no-response': 'no-response' };
14
+ const { peersOf, unattributedPeerDrops } = require('./peer-split');
15
+
16
+ // __proto__: null — an inherited/unknown action (e.g. "toString") must fall
17
+ // through the `|| 'no-response'` guards below and in run-debate.js, never
18
+ // resolve off Object.prototype: a function value there survives in memory
19
+ // but JSON.stringify drops it, silently deleting `action` from the finding.
20
+ // Defense-in-depth: `parse-stage2.js :: parseDebateDefense` is an
21
+ // allowlist that normalises every foreign action to `'no-response'`
22
+ // before this table is consulted — no real run reaches it with a
23
+ // foreign key. See `run-debate.test.js :: DOUBLEBREACH`.
24
+ const PAST_TENSE = { __proto__: null, defend: 'defended', amend: 'amended', withdraw: 'withdrawn', 'no-response': 'no-response' };
15
25
 
16
26
  // The debate-role vocabulary: a debate leg is an extra leg by an already-benched model,
17
27
  // never an extra ledger row and never that model's ledger identity. Through v4.6 this Set
@@ -23,12 +33,44 @@ const DEBATE_ROLES = new Set(['rebuttal', 'revote']);
23
33
 
24
34
  /**
25
35
  * Reassemble the tally input after the debate round.
26
- * @param {{tallyInput: object, provisionalRecord: object|null,
36
+ *
37
+ * v4.8 PR3 Task 6 (spec §4.5): `defenseByRaiser` and `revoteByJudge` are keyed
38
+ * on the SEAT (`seat.id`), which equals the alias for every bench without a
39
+ * repeated entry. `aliasOf` is the ONE seat→alias projection run-debate.js
40
+ * builds from `o.seats`; it is the identity for any key that is not a known
41
+ * seat id, so a direct-require caller with no seats and the reserved 'claude'
42
+ * key both behave exactly as before.
43
+ *
44
+ * ⚠️ SI-13: when `aliasOf` is omitted, the fail-open push below uses the raw
45
+ * key unchanged (`alias = key`), leaving the SEAT id in the alias-space
46
+ * `judge` field instead of projecting it to its alias. `judge` must stay
47
+ * alias-space: via `peer-split.js :: peersOf`'s `v.judge !== f.raiser`, a
48
+ * seat id there can silently retier a finding. `report.js :: toModel` never
49
+ * retiers — it copies `f.tier`, never recomputes it — but the same seat id
50
+ * still reaches its `columnFor` join as an out-of-contract `judge`.
51
+ * `applyDebate` has no seat roster of its own, so building that projection
52
+ * stays the caller's obligation.
53
+ *
54
+ * The gap needs a caller that both omits `aliasOf` and hits a repeated alias,
55
+ * and none exists today: the sole non-test caller, `run-debate.js`, always
56
+ * supplies `aliasOf` (see the warning comment at its call site), and
57
+ * `package.json`'s `exports` map publishes only `./opencode-client`, blocking
58
+ * a deep import of `amicus/src/council/debate` from outside this package.
59
+ *
60
+ * ⚠️ This block used to declare a `provisionalRecord` param the destructure has
61
+ * never taken (pre-existing doc rot). Callers still pass one and it is still
62
+ * ignored — the DOC is what was wrong, so only the doc was fixed. Deliberately
63
+ * not wired up: applyDebate reads `previousTier` off `tallyInput.findings[]`,
64
+ * which run-debate.js stamps, and a second source of that fact is how it
65
+ * silently read null before.
66
+ *
67
+ * @param {{tallyInput: object,
27
68
  * defenseByRaiser: Object<string, Object<string, object>>,
28
- * revoteByJudge: Object<string, Object<string, {verdict, reason?}>>}} args
69
+ * revoteByJudge: Object<string, Object<string, {verdict, reason?}>>,
70
+ * aliasOf?: function(string): string}} args
29
71
  * @returns {{input: object, debateFindings: Array}}
30
72
  */
31
- function applyDebate({ tallyInput, defenseByRaiser, revoteByJudge }) {
73
+ function applyDebate({ tallyInput, defenseByRaiser, revoteByJudge, aliasOf }) {
32
74
  // Deep-ish clone the mutable arrays we touch (findings + adjudications).
33
75
  const findings = tallyInput.findings.map(f => ({ ...f }));
34
76
  const adjudications = tallyInput.adjudications.map(a => ({ ...a }));
@@ -54,12 +96,29 @@ function applyDebate({ tallyInput, defenseByRaiser, revoteByJudge }) {
54
96
  delete f.previousTier; // provisional-only scratch field, never written to tally input
55
97
  }
56
98
 
57
- // Re-vote replacement: replace the wave judge's entry on each bundled id.
58
- for (const [judge, perId] of Object.entries(revoteByJudge || {})) {
99
+ // Re-vote replacement: replace the wave SEAT's entry on each bundled id.
100
+ // `(a.seat || a.judge)` is the seat-space read every consumer uses — `seat` is
101
+ // emit-when-different (Task 5), so a bench with no repeated alias reduces this
102
+ // to today's `a.judge === key` exactly. Matching on the alias instead is D5:
103
+ // two twins share one `judge` value, so the first row wins and the second
104
+ // twin's re-vote is silently dropped.
105
+ for (const [key, perId] of Object.entries(revoteByJudge || {})) {
59
106
  for (const [id, rv] of Object.entries(perId)) {
60
- const entry = adjudications.find(a => a.findingId === id && a.judge === judge);
107
+ const entry = adjudications.find(a => a.findingId === id && (a.seat || a.judge) === key);
61
108
  if (entry) { entry.verdict = rv.verdict; }
62
- else { adjudications.push({ findingId: id, judge, verdict: rv.verdict }); }
109
+ else {
110
+ // Fail-open push (a stateless leg re-voting an id it never adjudicated).
111
+ // `judge` MUST stay alias-space: via peer-split.js :: peersOf's
112
+ // `v.judge !== f.raiser`, a seat id there can silently retier a
113
+ // finding. report.js :: toModel never retiers — it copies `f.tier`,
114
+ // never recomputes it — but the same seat id still reaches its
115
+ // `columnFor` join as an out-of-contract `judge`.
116
+ // The seat rides beside it, emitted only when it differs — so a unique
117
+ // bench pushes today's exact row.
118
+ const alias = aliasOf ? aliasOf(key) : key;
119
+ adjudications.push({ findingId: id, judge: alias, verdict: rv.verdict,
120
+ ...(alias !== key ? { seat: key } : {}) });
121
+ }
63
122
  }
64
123
  }
65
124
 
@@ -88,7 +147,10 @@ function decorateRecord(record, debateFindings) {
88
147
  * 'superseded' for an original leg a successful repair replaced, and 'repair' for
89
148
  * a repair attempt that itself never became usable (error status rides naturally
90
149
  * off the raw leg). The rebuttal/revote legs never enter meta.models, so the
91
- * ledger stays one row per (run×model). DEBATE_ROLES remains the debate-role
150
+ * ledger stays one row per (run × model × resolvedModel) pair a debate round
151
+ * can never ADD a row (v4.8 PR4b: meta.models is still the row driver, and the
152
+ * pair fan-out only splits an alias whose own joinable rows resolved
153
+ * differently). DEBATE_ROLES remains the debate-role
92
154
  * vocabulary (rebuttal/revote); the ledger's overwrite protection for ALL FOUR
93
155
  * of these row-per-launch roles — rebuttal, revote, superseded AND repair —
94
156
  * lives in ledger.js's own LEDGER_JOIN_ROLES allowlist (v4.7 D4, Task 7):
@@ -129,20 +191,52 @@ function nothingToDebate(provisionalRecord) {
129
191
  return n === 0;
130
192
  }
131
193
 
132
- /** Judges whose provisional adjudications dispute at least one bundled id. */
194
+ /**
195
+ * Seats whose provisional adjudications dispute at least one bundled id.
196
+ * v4.8 PR3 Task 6: returns SEAT ids and dedups by seat (D6) — deduping by alias
197
+ * collapses two disputing bench positions into one re-vote leg, so the twin that
198
+ * never re-voted keeps a verdict the round meant to replace. run-debate.js
199
+ * projects these back to aliases for the launcher.
200
+ */
133
201
  function disputingJudges(provisionalRecord, bundledIds) {
134
202
  const ids = new Set(bundledIds);
135
203
  const judges = new Set();
136
204
  for (const f of provisionalRecord.findings) {
137
205
  if (!ids.has(f.id)) { continue; }
138
206
  for (const adj of f.adjudications || []) {
139
- if (adj.verdict === 'dispute') { judges.add(adj.judge); }
207
+ if (adj.verdict === 'dispute') { judges.add(adj.seat || adj.judge); }
140
208
  }
141
209
  }
142
210
  return [...judges];
143
211
  }
144
212
 
145
- /** Group Contested+Disputed findings by raiser (defense targets). */
213
+ /**
214
+ * Group Contested+Disputed findings by raiser SEAT (defense targets).
215
+ * v4.8 PR3 Task 6: keyed on `f.raiserSeat || f.raiser`. Claude's findings carry
216
+ * no `raiserSeat`, so its key stays the literal 'claude' and run-debate.js's
217
+ * `.filter(m => m !== 'claude')` / `byRaiser.claude` keep working unchanged.
218
+ * v4.8 Phase 2 T-B2: `peerVerdicts` below CALLS peer-split.js :: peersOf —
219
+ * the one predicate tally.js also calls — so the defense brief's peer split and
220
+ * the tally the chair reads agree by construction rather than by two spellings
221
+ * kept in step by hand. Until T-B2 this file spelled its own copy with TWO
222
+ * branches while `peersOf` had THREE: the outer `f.raiser ? … : …` arm was
223
+ * missing here, so a finding whose raiser is falsy ('' from the MCP path,
224
+ * `undefined` from the CLI path) was briefed a peer split the tally never
225
+ * computed. `unattributedPeerDrops` rides beside it under the same emit rule as
226
+ * tally.js's, so the two documents also carry the same mark.
227
+ * v4.8 T-B4 then changed what that branch COMPUTES — a falsy raiser no longer
228
+ * corroborates itself, and the seat compare was lifted ABOVE the raiser test so
229
+ * the seats decide first whatever `f.raiser` says (P0). Because both documents
230
+ * read the one function, the brief moved with the tally at no cost here. Two
231
+ * consequences are visible from this file: the briefed peer split of a
232
+ * falsy-raiser finding changes, and some such findings no longer reach a brief
233
+ * at all, because dropping their unattributable votes moves them off
234
+ * Contested/Disputed and `debateTargets` skips every other tier.
235
+ * debate.js :: applyDebate reads `(a.seat || a.judge)` and
236
+ * debate.js :: disputingJudges reads `adj.seat || adj.judge` — both already
237
+ * seat-space, both read at T-B2 — so this was the last hand-rolled peer filter
238
+ * left in this file.
239
+ */
146
240
  function debateTargets(provisionalRecord, tallyInput) {
147
241
  const claimById = new Map(tallyInput.findings.map(f => [f.id, f]));
148
242
  const byRaiser = {};
@@ -151,9 +245,20 @@ function debateTargets(provisionalRecord, tallyInput) {
151
245
  if (f.tier !== 'Contested' && f.tier !== 'Disputed') { continue; }
152
246
  previousTier[f.id] = f.tier;
153
247
  const src = claimById.get(f.id) || {};
154
- const peerVerdicts = (f.adjudications || []).filter(a => a.judge !== f.raiser).map(a => a.verdict);
155
- (byRaiser[f.raiser] = byRaiser[f.raiser] || []).push({ id: f.id, claim: src.claim,
156
- severity: f.severity, location: src.location, peerVerdicts, disputeReasons: [] });
248
+ // ⚠️ The trailing `.map(a => a.verdict)` is load-bearing: briefings-debate's
249
+ // verdictCounts indexes its counter BY THE ELEMENT, so a list of
250
+ // adjudication OBJECTS renders "0 dispute, 0 agree, 0 neutral" — a silent
251
+ // all-zero byte-identical to the no-data case, i.e. a paid brief telling the
252
+ // model nobody disputed it.
253
+ const adjs = f.adjudications || [];
254
+ const peerVerdicts = peersOf(f, adjs).map(a => a.verdict);
255
+ // Emitted only when > 0, exactly as tally.js emits it, so a run that does
256
+ // not orphan one side of a twin pair produces a byte-identical row.
257
+ const drops = unattributedPeerDrops(f, adjs);
258
+ const raiserKey = f.raiserSeat || f.raiser;
259
+ (byRaiser[raiserKey] = byRaiser[raiserKey] || []).push({ id: f.id, claim: src.claim,
260
+ severity: f.severity, location: src.location, peerVerdicts,
261
+ ...(drops > 0 ? { unattributedPeerDrops: drops } : {}), disputeReasons: [] });
157
262
  }
158
263
  return { byRaiser, previousTier };
159
264
  }