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,220 @@
1
+ // src/council/run-stage1-rows.js
2
+ 'use strict';
3
+ // Superseded-seat rows + primary-error dead-seat rows (v4.7 D2/E4), moved
4
+ // verbatim from run-stages.js:230-279 (v4.8 PR0 size-gate split, zero
5
+ // behavior). The code ran inline in runStage1; it is now a function.
6
+ // roleFor and seatOf are PARAMETERS, not requires — requiring them back from
7
+ // run-stages would recreate the parent-child cycle that file's tail comment
8
+ // documents eliminating (v4.4.1 F5).
9
+ const { buildRunStatsEntry } = require('./run-assemble');
10
+ // The one keyspace. It lives in ./run-retry-keys (v4.8 Phase 2 T-A1), which IS require-free;
11
+ // run-retry-group.js re-exports it and requires nothing else, so this import's closure
12
+ // terminates at a leaf and cannot re-create the parent-child cycle the header above
13
+ // documents eliminating.
14
+ const { twinAliases, legLossKey } = require('./run-retry-group');
15
+ // v4.8 T-A6 size-gate split: the SUPERSEDED half of these rows moved to
16
+ // ./run-stage1-superseded, taking `../utils/degrade` and the default stderr sink with it.
17
+ // `degrade` is still a parameter HERE, forwarded unchanged, so a caller that omits it still
18
+ // gets that stderr sink — and since council C1 so does one that passes `null` or a `.note`-less
19
+ // object, because that module now resolves the sink by testing the method rather than by a
20
+ // destructuring default. That module requires exactly the two modules the moved block already
21
+ // required from this file — ./run-assemble and ../utils/degrade — so the split adds no import
22
+ // edge the tree did not already carry, and the parent-child cycle the header above documents
23
+ // eliminating stays eliminated. Re-exported at the foot of this file, which is what gives the
24
+ // extraction a second import path to pin function identity across.
25
+ const { supersededRows } = require('./run-stage1-superseded');
26
+
27
+ /**
28
+ * Push superseded-seat and primary-error dead-seat rows onto extraRows.
29
+ *
30
+ * v4.8 PR2b Task 8: every alias->leg join below is SEAT-keyed. An alias is not
31
+ * a seat identity: two dead twins used to collapse into ONE dead-seat row, and
32
+ * one twin's first leg could be handed to the other twin as its "final" leg.
33
+ *
34
+ * ⚠️ `seatOf` MUST be the Stage-1 ∪ retry union (run-stages.js's `allSeatOf`).
35
+ * `retry.recoveredLegs` and `retry.stillDeadRetryLegs` are RETRY-wave leg
36
+ * objects that Stage-1's own object-keyed map has never seen, so a Stage-1-only
37
+ * map makes every lookup here return undefined and the whole re-key inert.
38
+ *
39
+ * ⚠️ The row's `model` stays the ALIAS (spec §4.7): a seat id must never appear
40
+ * in a ledger row, because pickFallbackChair launches `top.aliases[0]` and
41
+ * 'deepseek#2' is not routable. Two RUNSTATS rows both reading
42
+ * `model: 'deepseek'` remains the CORRECT outcome for two dead twins — runStats
43
+ * is row-per-launch and both seats were paid for. What changed in v4.8 PR4b is
44
+ * downstream: the ledger now groups by (model, resolvedModel), so the LEDGER row
45
+ * count for two dead twins depends on whether their seats produced a leg at all.
46
+ * Two dead twins whose seats produced NO leg (`finalLeg` null — the srcLegStillDead
47
+ * and missingLegStillDead classes below) share the empty resolved key and collapse
48
+ * into ONE ledger row. Two whose still-dead RETRY legs came back resolved
49
+ * DIFFERENTLY still produce TWO, keyed by executable: retryLegBySeat (below)
50
+ * surfaces a real leg, and buildRunStatsEntry stamps `resolvedModel` from
51
+ * `leg.model` whether that leg succeeded or not. Either way runStats keeps two rows.
52
+ * ⚠️ v4.8 council A1 NARROWED that second sentence: it holds for a twin whose retry leg
53
+ * was IDENTIFIED (the retryLegBySeat path). An UNATTRIBUTED twin's row now takes only a
54
+ * borrowed leg's `usage` and NO `resolvedModel`, so two of those share the empty resolved
55
+ * key and collapse into ONE ledger row — the same outcome as the no-leg classes above.
56
+ * MEASURED against buildLedgerRows, not reasoned: two such rows whose borrowed legs
57
+ * resolved DIFFERENTLY gave 2 ledger rows before and give 1 now; two that resolved the
58
+ * SAME gave 1 either way. Nothing about spend moves — a ledger row carries no cost field
59
+ * at all (`buildLedgerRows`' keys are stats, not usage), and runStats, which IS what the
60
+ * run total is summed from, still keeps two rows carrying both legs' `usage`. What the
61
+ * ledger loses is an executable split that `shift()` assigned by arrival order.
62
+ */
63
+ function pushDeadSeatRows({ o, retry, deadLegs0, stillDeadLegs, stillDeadWaves, extraRows,
64
+ roleFor, seatOf, degrade, twins = twinAliases(o.seats) }) {
65
+ // A leg's join key: its bound seat's id, else its alias — the same fallback
66
+ // `run-retry-keys.js :: seatKey` (imported by run-retry.js since v4.8 T-A1) uses
67
+ // for a roster slot it could not identify, so both sides of every lookup agree.
68
+ const keyOf = (leg) => { const s = seatOf.get(leg); return s ? s.id : (leg.modelInput || leg.model); };
69
+ // v4.8 T2.2 ruling R2, the LEG arms' MINT branch: on an alias the roster repeats, an
70
+ // unbound leg's `keyOf` names BOTH twins, so N dead legs collapsed into ONE row.
71
+ // `legLossKey` falls back to the leg's own taskId there — a real per-leg id, not an
72
+ // inferred seat. It is identical to `keyOf` everywhere else, so only the twin case
73
+ // moves. Deliberately NOT used for `supersededKeys` — which since v4.8 T-A6 lives in
74
+ // `run-stage1-superseded.js :: supersededRows` — nor for `retryLegBySeat` below:
75
+ // those join FIRST-attempt legs against RETRY legs, two different leg populations
76
+ // whose taskIds can never match, and minting there would silently drop the
77
+ // superseded row of every twin whose retry healed.
78
+ // v4.8 T-A6 (SI-TWINS): `twins` is the run's ONE `twinAliases`, threaded in — run-stages.js
79
+ // passes `retry.twins`, the very collection `run-retry.js :: retryStage1Losses` minted every
80
+ // `attemptedSeats` key with. The default is for direct callers (several suites hand this
81
+ // function a fixture `retry` with no `twins` at all), and it re-derives from the SAME roster,
82
+ // so it agrees by construction — but only the threaded value agrees by IDENTITY.
83
+ const rowKeyOf = (l) => legLossKey(seatOf.get(l) || null, l.modelInput || l.model, l, twins);
84
+
85
+ // v4.7 D2/E4 superseded rows + the T-A5 invariant guard: ./run-stage1-superseded (T-A6 split).
86
+ // Appended HERE, before the dead-seat loop, exactly where the moved loop stood — and handed the
87
+ // SAME `keyOf`/`rowKeyOf` this function built, never a second derivation of either.
88
+ extraRows.push(...supersededRows({ retry, deadLegs0, keyOf, rowKeyOf, degrade }));
89
+
90
+ // v4.7 D2/E4 — primary error rows: one per SEAT with NO surviving review
91
+ // (every seat still in stillDeadLegs/stillDeadWaves after retry). E5 amended
92
+ // (Task-4 review, owner-ruled): run-retry.js surfaces the real retry leg
93
+ // (stillDeadRetryLegs), from the ONE branch it exists in —
94
+ // retryLegStillDeadNote, a retry leg that came back unusable. Prefer that
95
+ // REAL leg: status/waveId/usage/duration all real, all from the SAME attempt
96
+ // (no more pairing a retry's waveId with a different attempt's status). The
97
+ // other two dead classes — the retry wave died wholesale (srcLegStillDead),
98
+ // and a partial return that never named this seat (missingLegStillDead) —
99
+ // never get a real leg, so `leg: null` (no phantom waveId: one must never
100
+ // appear without a real billed leg).
101
+ // v4.8 T2.2: a still-dead RETRY leg that named no seat on a twin alias cannot be
102
+ // MATCHED to a row — but it was billed, and dropping it hides the spend that this
103
+ // whole change exists to record. Every unattributed row on that alias is equally
104
+ // dead, so they are handed out one apiece below: the SET of billed legs on the
105
+ // record is exact, and no row claims a seat identity (all carry seat: null).
106
+ // ⚠️ Which row gets which spare is arbitrary in BOTH directions, and stays arbitrary:
107
+ // `shift()` takes the next spare in arrival order, and the rows it feeds are the `!exact`
108
+ // rows of BOTH arms — so a LEG-origin retry leg can be claimed by a WAVE-origin row (a
109
+ // `Symbol('unattributed-seat')` slot below) whose seat produced no leg at all, and two
110
+ // leg-origin rows can swap. v4.8 council A1 bounds what that arbitrariness can SAY: a
111
+ // claimed spare is BILLING ONLY. Its `usage` rides the row, because the run paid for it
112
+ // and dropping it re-opens the spend hole this change exists to close; its `waveId`,
113
+ // `resolvedModel`, `status` and `durationMs` do NOT, because those are one seat's
114
+ // execution and the row is another's — a row that produced no leg would otherwise be
115
+ // emitted with a real attempt's duration and outcome. What remains, disclosed: the SPLIT
116
+ // of a known alias total across its anonymous rows is still arbitrary (row order, not
117
+ // identity). The SET, COUNT and SUM of billed legs are exact, and no row now asserts an
118
+ // execution it cannot own. Pinned by BORROWALL (a row must never differ from the leg-less
119
+ // row by anything but `usage`) — deleting the `usage` line reds the spend half instead.
120
+ const retryLegBySeat = new Map();
121
+ const spareRetryLegs = new Map(); // alias -> still-dead retry legs naming no seat
122
+ for (const leg of retry.stillDeadRetryLegs) {
123
+ const alias = leg.modelInput || leg.model;
124
+ if (seatOf.get(leg) || !twins.has(alias)) { retryLegBySeat.set(keyOf(leg), leg); continue; }
125
+ if (!spareRetryLegs.has(alias)) { spareRetryLegs.set(alias, []); }
126
+ spareRetryLegs.get(alias).push(leg);
127
+ }
128
+
129
+ // `exact` says the key names ONE seat; `join` is what the lookups below ask with.
130
+ const deadSeats = new Map(); // row key -> { seat, alias, exact, join }
131
+ for (const l of stillDeadLegs) {
132
+ const seat = seatOf.get(l) || null;
133
+ const alias = l.modelInput || l.model;
134
+ const key = rowKeyOf(l);
135
+ deadSeats.set(key, { seat, alias, exact: !!seat || !twins.has(alias), join: key });
136
+ }
137
+ for (const w of stillDeadWaves) {
138
+ // `models` and `seats` are narrowed in LOCKSTEP by run-retry.js's
139
+ // reconcile, so index-zipping them is safe here and nowhere else.
140
+ (w.models || []).forEach((alias, i) => {
141
+ const s = (w.seats || [])[i] || null;
142
+ const join = s ? s.id : alias;
143
+ // R2's MARK branch. This arm has no leg to mint from, and `(waveId, i)` is
144
+ // measurably NOT unique — missingSeatDeadWave emits several records under one
145
+ // waveId each with i === 0, and run-retry.js re-indexes when it narrows a
146
+ // partially healed wave — so inventing an id here would silently merge two
147
+ // distinct seats. A Symbol marks the slot instead: it cannot be joined,
148
+ // compared to a seat id, or mistaken for one, and the row it makes attributes
149
+ // nothing. The ANNOUNCEMENT is the wave's own dead-wave note, whose
150
+ // `data.models`/`data.seats` already carry one entry per slot (PR5c), null for
151
+ // the unnamed ones — which is what a reader sees instead of an id.
152
+ const exact = !!s || !twins.has(alias);
153
+ deadSeats.set(exact ? join : Symbol('unattributed-seat'), { seat: s, alias, exact, join });
154
+ });
155
+ }
156
+
157
+ for (const [, { seat, alias, exact, join }] of deadSeats) {
158
+ // ⚠️ Safe only because `stage1-bind.js :: bindPaddedWave` — which
159
+ // `run-retry-launch.js :: bindRetryWave` calls — DROPS every placeholder bind and keeps
160
+ // placeholder ids unique. Together they guarantee a BOUND still-dead retry leg always
161
+ // resolves `exact` here, never the branch below. Break that conjunction and a bound retry
162
+ // leg's usage is lost silently. Measured unreachable today — see BACKLOG.md's PR #170
163
+ // T2.2 section (the retry-leg-drop finding).
164
+ // ⚠️ Symbol anchors, never bare line numbers. This comment used to name those two
165
+ // guarantees as `(:59)`/`(:53)` into run-retry-launch.js; v4.8 SI-27 moved the block and
166
+ // both went out of range, and `check:citations` stayed GREEN — its CITATION regex
167
+ // requires a `.js` path immediately before `:NNN`, so a bare-paren line ref is invisible
168
+ // to the gate. A green citation gate proves nothing about that form.
169
+ let finalLeg = exact ? retryLegBySeat.get(join) : undefined;
170
+ // v4.8 council A1: claim the spare HERE, and never as `finalLeg`. It is consumed exactly
171
+ // as before — same pool, same `shift()`, same one-apiece hand-out, so the SET of billed
172
+ // legs on the record is unchanged — but it reaches the row through `usage` alone below.
173
+ // Mutually exclusive with the `deadLegs0` fallback: that branch runs only when
174
+ // `attemptedSeats` does NOT hold `join`, which is the condition this one requires.
175
+ const borrowed = !finalLeg && !exact && retry.attemptedSeats.has(join)
176
+ ? (spareRetryLegs.get(alias) || []).shift() || null
177
+ : null;
178
+ if (!finalLeg) {
179
+ // `retry.attemptedSeats` — never a scan of stillDeadNotes. Those notes'
180
+ // `data.seat` is ALIAS-valued by contract, so no twin's seat id could
181
+ // ever match one, and this fallback would re-attach a first-attempt leg
182
+ // to a seat that WAS retried. It is also channel-blind on purpose: a
183
+ // still-dead seat rides `dead-leg`, `dead-wave` OR `seat-unbound`
184
+ // depending on how it was lost, and all three mean "we retried it".
185
+ // ⚠️ v4.8 T2.2: `join` and `attemptedSeats` MUST move in lockstep. run-retry.js
186
+ // adds BOTH spellings (the seat key AND legLossKey's), so a minted row asks a
187
+ // question this Set can answer. Mint here alone and the predicate flips: every
188
+ // retried twin re-acquires its own first-attempt leg — which already has its
189
+ // own `superseded` row above, so that leg's cost lands in runStats twice.
190
+ finalLeg = retry.attemptedSeats.has(join)
191
+ ? null
192
+ : (deadLegs0.find(l => rowKeyOf(l) === join) || null); // never retried
193
+ }
194
+ // Seat-space role (spec §4.5), matching the review push in run-stages.js:
195
+ // the SEAT's own role, NOT roleAt(o.seats, seat.id) — o.seats is absent on
196
+ // the buildSeats fallback path while the seat is not, so roleAt's unknown-id
197
+ // 'seat' collapses every critic/lens role. Unidentified seats keep the shim.
198
+ // v4.8 PR4c §3.1: `seat` is the seat OBJECT (buildRunStatsEntry compares
199
+ // its id to its own alias) — never `seat.id`, which would make both sides
200
+ // of that comparison undefined and the stamp silently inert. Null here is an
201
+ // orphaned seat: since v4.8 T2.2 two orphaned twins that BOTH reach this loop get
202
+ // TWO rows (they are two seats the run paid for), each carrying no seat at all.
203
+ // ⚠️ Scoped on purpose — this function emits one row per still-dead input it is GIVEN, and
204
+ // cannot emit a row for a seat that never arrives. When a retry wave returned FEWER legs than
205
+ // it launched, `run-retry.js`'s alias-granular `launched` reconcile passed only ONE of two
206
+ // unattributable twins, so the run showed one row. v4.8 T-A4 closed that half in the PRODUCER:
207
+ // it emits `max(slots, 1) - seen` notes now, so both twins arrive here. Nothing here changed.
208
+ const row = buildRunStatsEntry({ leg: finalLeg, model: alias, seat,
209
+ role: seat ? seat.role : roleFor(o, alias), wasChair: false });
210
+ // Overwrite rather than synthesize a leg: `buildRunStatsEntry({leg: null})` is the ONE
211
+ // definition of "a dead seat with nothing to report", so a borrowed row is that row plus
212
+ // one field, and it stays that way if the leg-less defaults ever change. A synthetic
213
+ // `{usage, status}` stub would fork them silently — and a bare `{usage}` stub is worse,
214
+ // because `leg ? leg.status : 'error'` would then stamp `status: undefined`.
215
+ if (borrowed) { row.usage = borrowed.usage || null; }
216
+ extraRows.push(row);
217
+ }
218
+ }
219
+
220
+ module.exports = { pushDeadSeatRows, supersededRows };
@@ -0,0 +1,156 @@
1
+ // src/council/run-stage1-superseded.js
2
+ 'use strict';
3
+ // The SUPERSEDED half of the Stage-1 extra rows, lifted out of
4
+ // `run-stage1-rows.js :: pushDeadSeatRows` (v4.8 T-A6 size-gate split, zero behaviour): which
5
+ // FIRST-attempt legs stop being primary, and the one invariant that join can check for itself.
6
+ // A CLOSURE LIFT, not a byte-for-byte move — `keyOf` and `rowKeyOf` stay with the caller,
7
+ // because the dead-seat half left behind needs both, so they ride in as parameters. And it
8
+ // RETURNS its rows rather than pushing onto the caller's `extraRows`: an extracted helper that
9
+ // mutates its caller's accumulator is the shape this release ruled out.
10
+ // Its two requires are the two the moved block already had — ./run-assemble and
11
+ // ../utils/degrade — so the split added no import edge the tree did not already carry.
12
+ // MEASURED, not argued: their eager require closure is 13 first-party modules and contains
13
+ // neither run-stages.js nor run-stage1-rows.js nor this file, so the parent-child cycle
14
+ // run-stage1-rows.js's own header documents eliminating stays eliminated on this path too.
15
+ const { buildRunStatsEntry } = require('./run-assemble');
16
+ // v4.8 T-A5: the ONE voice, for the one thing this file can now detect and refuse (below).
17
+ // ../utils/degrade requires nothing at all, so this is a leaf import and the cycle the
18
+ // header above documents eliminating stays eliminated.
19
+ const { formatDegrade } = require('../utils/degrade');
20
+ // The announcement must not be defeatable by the caller's sink, so anything that cannot carry it
21
+ // falls back to the same sentence on stderr — resolved inside `supersededRows` (see `sink` there),
22
+ // never by a parameter default, which substitutes only for `undefined`.
23
+ // Nothing on this path may throw — it runs after a whole council has
24
+ // already been paid for — so BOTH halves are covered: `formatDegrade` is pure interpolation, and
25
+ // the sink's validating `makeDegrade` is deliberately not called; and the write itself is wrapped,
26
+ // because a stream can fail even when the string cannot. Same guard, same reason, as the real sink
27
+ // (`run-degrade.js :: createDegradeSink`'s `safeEmit` — anchored by symbol; it is the only one).
28
+ const STDERR_NOTICE = { note: (r) => {
29
+ try { process.stderr.write(formatDegrade({ ...r, kind: 'degrade' })); } catch { /* EPIPE etc */ }
30
+ } };
31
+
32
+ /**
33
+ * The `superseded` rows for one Stage-1 pass, in `deadLegs0` order.
34
+ *
35
+ * `keyOf` and `rowKeyOf` are the CALLER's — passed in, never re-derived here, and that is the
36
+ * point: this join and the dead-seat rows must ask the SAME keyspace the SAME question. A second
37
+ * spelling of either, or a second `twinAliases` behind `rowKeyOf`, is the desync the mutants
38
+ * DESYNCLEG and DESYNCPLAN pin (see `run-retry-keys.js :: legLossKey` for what the mint buys).
39
+ *
40
+ * @param {object} a.retry the `retryStage1Losses` return value
41
+ * @param {Array} a.deadLegs0 Stage-1's first-attempt dead legs, in their original order
42
+ * @param {Function} a.keyOf leg -> its bound seat's id, else its alias (the caller's)
43
+ * @param {Function} a.rowKeyOf leg -> its ROW key, `legLossKey`-minted (the caller's)
44
+ * @param {object} [a.degrade] the one voice; anything without a callable `.note` — omitted,
45
+ * null, or malformed — falls back to the stderr notice above
46
+ * @returns {Array<object>} rows for the caller to append — this function appends nothing.
47
+ */
48
+ function supersededRows({ retry, deadLegs0, keyOf, rowKeyOf, degrade }) {
49
+ // ⚠️ v4.8 council C1 — resolved HERE, by testing the METHOD, not by a destructuring default.
50
+ // A parameter default substitutes only for `undefined`, so `degrade: null` and `degrade: {}`
51
+ // both reached `degrade.note(...)` and threw a TypeError. MEASURED through this function in
52
+ // the refusal shape below before this line existed: omitted and a real sink returned 1 row,
53
+ // null and a `.note`-less object threw. That was the SECOND throw path in this one guard
54
+ // (round 1 closed the first, an EPIPE from the sink's own write) and it is the one the
55
+ // `Channel internal` paragraph below already rules out in words: a throw here aborts a council
56
+ // that has already been paid for, over a row miscount. Not reachable in production —
57
+ // run-stages.js hands `ctx.degrade` to `pushDeadSeatRows`, which forwards it here, and `run.js`
58
+ // builds that sink with `createDegradeSink`, PROBED to return a `{note, all}` object whose
59
+ // `note` is callable — but reachable from any fixture or future caller, which is the
60
+ // population a fallback sink exists for at all.
61
+ const sink = degrade && typeof degrade.note === 'function' ? degrade : STDERR_NOTICE;
62
+ const rows = [];
63
+ // v4.7 D2/E4 — superseded rows: a leg-origin seat's FIRST leg stops being
64
+ // primary the moment a retry was actually attempted for it, healed or not
65
+ // (deadLegs0 × recovered-or-still-dead seats — mirrors the healed-set idiom
66
+ // above, extended to the still-dead half E4 also requires). A skipped seat
67
+ // (cost ceiling / unmappable — never got a second leg) keeps NO superseded
68
+ // row: nothing replaced it. Wave-origin seats never had a first leg at all,
69
+ // so they can never appear here regardless of healed/dead outcome (E4).
70
+ // ⚠️ v4.8 T2.2 — this is the ONE join in this function still in the ALIAS-granular
71
+ // keyspace, and it stays safe only while a twin alias cannot produce one RETRIED and one
72
+ // SKIPPED leg. Both sides of the join are LEG-origin (`deadLegs0` below, and retry's
73
+ // recovered/still-dead LEG arrays above — a wave-origin seat has no first leg at all and
74
+ // reaches neither), so the shape to rule out is two UNBOUND LEG-origin twins in different
75
+ // retry units. Two facts, read off run-retry.js and run-retry-group.js rather than
76
+ // inferred: (1) skipping is all-or-nothing per UNIT — every skip branch pushes
77
+ // `...unit.srcWaves`/`...unit.srcLegs` wholesale and `continue`s; (2) such twins always
78
+ // share a unit — bench and critic are one unit each, and the deadLegs loop calls
79
+ // `lensIndexOf(o, null, alias, seatObj)` with `seatObj` null when unbound, falling through
80
+ // to `o.models.indexOf(alias)`, first-match, so both resolve to the SAME lens index.
81
+ // (BOUND twins never needed this: their `keyOf` values already differ. And a wave-origin
82
+ // twin CAN land in a different lens unit than its leg-origin sibling — that split is real,
83
+ // it just cannot reach this join.) Break either fact and the skipped twin takes its own
84
+ // first leg as a primary row AND gets a superseded row for it: one billed leg counted
85
+ // twice.
86
+ // ⚠️ v4.8 T-A5 — and the paragraph above is now the DERIVATION, not the safety. Both facts
87
+ // exist to make ONE statement true: no first leg is SKIPPED while its alias key is superseded.
88
+ // `retry.skippedDeadLegs` states that directly, in leg OBJECTS — the very members of
89
+ // `deadLegs0` the retry declined to attempt — so the test below asks IDENTITY, which no
90
+ // keyspace can blur, at the one place the alias key is relied on. Unreachable while either fact
91
+ // holds (measured over 4000 fuzzed retry runs, 993 of them with skips: 0 hits on the whole
92
+ // shipped condition, all three conjuncts, and 0 on the SKIPPED-and-superseded test alone), so
93
+ // every correct input is byte-identical — and the double count the paragraph above ends on no
94
+ // longer FOLLOWS from breaking a fact: it is refused and announced. Under mutant GUESSPOS the
95
+ // same fuzz hits 85 on both counts; its legs carry taskIds, so row keys are distinct there.
96
+ const skippedLegs = new Set(retry.skippedDeadLegs || []);
97
+ const supersededKeys = new Set([
98
+ ...retry.recoveredLegs.map(keyOf),
99
+ ...retry.stillDeadLegs.map(keyOf),
100
+ ]);
101
+ // Refusing the row is the repair — but ONLY for a leg the dead-seat loop would hand back as its
102
+ // own primary row, which is what `willTakeItsOwnLeg` decides. Since v4.8 T-A6 that loop is the
103
+ // CALLER's (`run-stage1-rows.js :: pushDeadSeatRows`), not this file's — so any "below" naming
104
+ // THAT LOOP points across the boundary (the other two still point inside). run-stages.js merges `skippedDeadLegs`
105
+ // into the `stillDeadLegs` it hands that caller, so a skipped leg IS a still-dead seat there,
106
+ // and that loop's `deadLegs0.find` fallback runs for exactly the keys `attemptedSeats` does NOT
107
+ // hold — and returns ONE leg per key.
108
+ // ⚠️ BOTH conjuncts are load-bearing and BOTH were learned by MEASUREMENT, not argument. The
109
+ // first version of this comment argued the second was unreachable; it was wrong, and the guard
110
+ // built on it lost billed spend (T-A5 rounds 1-3).
111
+ // `attemptedSeats` half: where the key IS held, R2's taskId-less floor has collapsed the twins
112
+ // onto one LEG-LESS row, so the superseded row is the only place that leg's `usage` survives.
113
+ // `=== dead` half: where the key is free but `find` would hand the row a DIFFERENT leg, this
114
+ // leg gets no row of its own and refusing drops its `usage` entirely. Reachable by breaking
115
+ // invariant 2 ALONE — `supersededKeys` also draws from `retry.recoveredLegs` just above, and NO
116
+ // writer of `attemptedSeats` sits on run-retry.js's HEAL branch (they are all on still-dead
117
+ // paths), so a healed twin supersedes the alias while `attemptedSeats` stays empty.
118
+ // Measured end to end, 3 unbound twins in one lens with invariant 2 broken: row keys COLLIDING —
119
+ // billed 0.60, recorded 0.70 both with this guard and without any guard, nothing lost; row keys
120
+ // DISTINCT — 1.10 without the guard against 0.60 with it, the whole 0.50 double count removed
121
+ // and nothing lost. Dropping either conjunct is a named mutant: WIDEGUARD and KEYNOTLEG.
122
+ // ⚠️ The 0.10 surviving on the COLLIDING shape is the R2 collapse floor, NOT a borrow —
123
+ // INSTRUMENTED, because two earlier versions of this sentence named a path that cannot execute
124
+ // here. The twins share ONE `deadSeats` entry and `attemptedSeats` is empty, so `deadLegs0.find`
125
+ // hands that row the HEALED twin's first leg, which already carries a superseded row: one leg,
126
+ // two rows. Probed at the push site — `finalLeg` is that leg (not null), `borrowed` null, spare
127
+ // pool empty. A borrow needs `attemptedSeats.has(join)` TRUE: the negation of this shape.
128
+ // It is announced either way, because a silently corrected number is the failure mode this join
129
+ // is watched for; a THROW would be wrong here, aborting a paid-for council over a row miscount.
130
+ // Channel `internal` — the runtime disagreed with itself, which is not a seat loss. All FOUR
131
+ // readers of a note's `data.seat` (verdict.js, workspace-seats.js, live-dead-seats.js,
132
+ // workspace/seat-space.js) gate on dead-leg/dead-wave/seat-unbound first, so it reaches none, and it cannot
133
+ // move the exit code either: run-stages.js notes a `dead-leg` degrade for every skipped leg
134
+ // before this function is called, so the run is already degraded whenever this can fire.
135
+ const willTakeItsOwnLeg = (dead) => !retry.attemptedSeats.has(rowKeyOf(dead))
136
+ && deadLegs0.find(l => rowKeyOf(l) === rowKeyOf(dead)) === dead;
137
+ const refuseSupersede = (dead) => {
138
+ const alias = dead.modelInput || dead.model;
139
+ sink.note({ channel: 'internal',
140
+ what: `a superseded row for seat ${alias} was refused`,
141
+ why: 'the retry both SKIPPED this first-attempt leg and superseded its alias key, which the '
142
+ + 'two invariants above forbid — so that key no longer names one outcome',
143
+ effect: 'the leg keeps its primary row and is counted once; the run continues, but the '
144
+ + `superseded/primary split for '${alias}' is no longer trustworthy`,
145
+ data: { seat: alias, taskId: dead.taskId || null } });
146
+ };
147
+ for (const dead of deadLegs0) {
148
+ if (!supersededKeys.has(keyOf(dead))) { continue; }
149
+ if (skippedLegs.has(dead) && willTakeItsOwnLeg(dead)) { refuseSupersede(dead); continue; }
150
+ rows.push(buildRunStatsEntry({ leg: dead, model: dead.modelInput || dead.model,
151
+ role: 'superseded', wasChair: false }));
152
+ }
153
+ return rows;
154
+ }
155
+
156
+ module.exports = { supersededRows };
@@ -24,6 +24,11 @@ const { parseJudgeOutput } = require('./parse-stage2');
24
24
  const { sanitizeName, isAbortExit } = require('./run-launch');
25
25
  const runState = require('./run-state');
26
26
  const { buildRunStatsEntry } = require('./run-assemble');
27
+ // v4.8 PR3 Task 4: seat binding. artifactName is NOT re-exported from
28
+ // run-launch.js (its exports stop at sanitizeName/isAbortExit), so it comes
29
+ // straight from ./seats — that module requires nothing, zero cycle risk.
30
+ const { artifactName } = require('./seats');
31
+ const { orphanLegNote, bindPaddedWave } = require('./stage1-bind');
27
32
 
28
33
  /**
29
34
  * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
@@ -71,6 +76,53 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
71
76
  ctx.addWave(wave);
72
77
  if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [], extraRows: [] }; }
73
78
 
79
+ // v4.8 PR3 Task 4: bind the -s2 wave's legs to the SAME seats reviews[] holds,
80
+ // in `reviews` order — judges is built from that same array by the same
81
+ // `.map`, and real fanout stamps `${waveId}-${i+1}` off that same index
82
+ // (sidecar/leg-ids.js), so this holds even after an SL-2 heal (recovered legs
83
+ // are appended, run-stages.js:153, so `reviews` order is no longer seat order
84
+ // — both arrays still derive from the same `reviews`).
85
+ // §3.4's padding pattern now lives in `stage1-bind.js :: bindPaddedWave`
86
+ // (v4.8 SI-27) — why the roster is padded rather than filtered, and why
87
+ // placeholders are tracked by IDENTITY rather than an id-name prefix test,
88
+ // are in that function's docblock. Only this site's TAIL stays here.
89
+ const s2WaveId = `${o.runId}-s2`;
90
+ // review F(critical): `wave` is legitimately null — a budget/args refusal
91
+ // (run-budget.js failPre) returns `{wave: null, exitCode: 1}`, which
92
+ // isAbortExit does NOT catch (only 130/143), so execution reaches here.
93
+ // Guarded exactly like the leg loop below (`(wave && wave.legs) || []`) —
94
+ // this is the only OTHER dereference of wave.legs in the file.
95
+ const s2Legs = (wave && wave.legs) || [];
96
+ const { seatOf: judgeSeatOf, bindRes, placeholders } =
97
+ bindPaddedWave(s2WaveId, reviews.map(r => r.seat), i => judges[i], s2Legs);
98
+ // An orphan leg (a judge DID land, but no roster slot claims it) gets the
99
+ // same shape as Stage 1's orphanLegNote — `data.legId` present.
100
+ for (const leg of bindRes.orphanLegs) { ctx.degrade.note(orphanLegNote(s2WaveId, leg)); }
101
+ // review F(important): mirrors stage1-bind.js:40's suppression rule
102
+ // verbatim. A wave that returned ZERO legs is already announced on a louder
103
+ // channel (thin-cross-review, or this refusal itself) — there is no
104
+ // "missing seat" fact this adds. An orphan leg means a judge DID land for
105
+ // SOME seat we could not name — reporting the roster's other unbound seats
106
+ // as "missing" would double-count that one failure as two, on a channel
107
+ // whose whole contract is "nothing was guessed": the stray leg might BE the
108
+ // seat this loop would otherwise call missing.
109
+ if (s2Legs.length > 0 && bindRes.orphanLegs.length === 0) {
110
+ for (const seat of bindRes.unbound) {
111
+ if (placeholders.has(seat)) { continue; }
112
+ ctx.degrade.note({
113
+ channel: 'seat-unbound',
114
+ what: `leg for seat ${seat.alias} in wave ${s2WaveId} never returned`,
115
+ // R27-5: `reviews.length` IS the padded roster's length — bindPaddedWave
116
+ // maps 1:1 over the source array, and `Array.prototype.map` preserves
117
+ // length. Pinned (`ROSTERLEN`) in run-stages.test.js, because a
118
+ // substitution into unpinned prose is how a true sentence goes quietly false.
119
+ why: `the wave returned fewer judge legs than its roster of ${reviews.length}`,
120
+ effect: 'That seat did not judge; nothing was guessed and nothing was dropped',
121
+ data: { waveId: s2WaveId, seat: seat.alias },
122
+ });
123
+ }
124
+ }
125
+
74
126
  const judgeResults = [];
75
127
  // v4.7 D2: every judge-repair launch is a billed leg of its own, distinct from
76
128
  // the judge's original Stage-2 wave leg it is trying to fix — it gets its own
@@ -80,8 +132,12 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
80
132
  let repairSeq = 0;
81
133
  for (const leg of (wave && wave.legs) || []) {
82
134
  const judge = leg.modelInput || leg.model;
135
+ const seat = judgeSeatOf.get(leg) || null;
83
136
  if (leg.status === 'complete' && leg.summary) {
84
- fs.writeFileSync(path.join(o.runDir, `judge-${sanitizeName(judge)}.md`), leg.summary, { mode: 0o600 });
137
+ // Mirrors the shape run-launch.js :: materializeReviews ships exactly:
138
+ // seat filename when bound, alias filename (today's behaviour) otherwise.
139
+ const name = seat ? artifactName(seat, 'judge') : `judge-${sanitizeName(judge)}.md`;
140
+ fs.writeFileSync(path.join(o.runDir, name), leg.summary, { mode: 0o600 });
85
141
  }
86
142
  let conformance = 'clean';
87
143
  let parsed = (leg.status === 'complete' && leg.summary)
@@ -126,7 +182,7 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
126
182
  if (parsed.ok) { conformance = 'repaired'; }
127
183
  }
128
184
  if (!parsed.ok) {
129
- judgeResults.push({ judge, ok: false, order: null, adjudications: null,
185
+ judgeResults.push({ judge, seat, ok: false, order: null, orderSeats: null, adjudications: null,
130
186
  conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
131
187
  // #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
132
188
  // Stage-1's convention (reviews carry the original wave leg even when a
@@ -137,9 +193,13 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
137
193
  leg: leg || null });
138
194
  continue;
139
195
  }
140
- const { order } = rankingToOrder(parsed.ranking, labels.labelMap);
141
- judgeResults.push({ judge, ok: true, order, adjudications: parsed.adjudications, conformance,
142
- leg: leg || null });
196
+ // v4.8 T3.2: labels.seatMap (anonymize.js :: assignLabels) threads through
197
+ // so orderSeats can disambiguate a twin bench's `order`, which stays
198
+ // alias-only. T3.3 wired it into street-cred.js :: rankPositions, via
199
+ // rankings[] in run-assemble.js :: buildTallyInput; `order` never moved.
200
+ const { order, orderSeats } = rankingToOrder(parsed.ranking, labels.labelMap, labels.seatMap);
201
+ judgeResults.push({ judge, seat, ok: true, order, orderSeats, adjudications: parsed.adjudications,
202
+ conformance, leg: leg || null });
143
203
  }
144
204
  return { aborted: null, judgeResults, extraRows };
145
205
  }