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,266 @@
1
+ // src/council/run-retry-group.js
2
+ 'use strict';
3
+ // Stage-1 loss grouping: lensIndexOf + recordFailure + groupStage1Losses.
4
+ // Moved verbatim from run-retry.js:24-126 (v4.8 PR0 size-gate split, zero
5
+ // behavior). Pure but for ONE leaf require: ./run-retry-keys, itself require-free.
6
+ // run-retry.js re-exports groupStage1Losses so existing import paths
7
+ // (tests/council/run-retry.test.js) stay stable.
8
+ const { seatKey, twinAliases, legLossKey, srcLegClaimer } = require('./run-retry-keys');
9
+
10
+ /** 1-based lens index for a loss: the waveId convention, else the seat's own
11
+ * bench position, else the alias's first bench index. v4.8 PR2b H4: the old
12
+ * `o.models.indexOf(model)` was first-match, so twin aliases both resolved to
13
+ * the FIRST twin's lens and shared one retry unit — and the deadLegs loop
14
+ * passes waveId=null, so that branch was the only one those losses could
15
+ * take. The alias fallback survives for a loss with no identified seat. */
16
+ function lensIndexOf(o, waveId, model, seatObj = null) {
17
+ const m = /-l(\d+)$/.exec(waveId || '');
18
+ if (m) { return Number(m[1]); }
19
+ if (seatObj && Number.isInteger(seatObj.position)) { return seatObj.position; }
20
+ const i = (o.models || []).indexOf(model);
21
+ return i === -1 ? null : i + 1;
22
+ }
23
+
24
+ /**
25
+ * Which of a unit's srcLegs still need their own still-dead note once its srcWaves have
26
+ * emitted theirs, plus every seat key those announcements cover.
27
+ *
28
+ * v4.8 PR5c: an UNIDENTIFIED wave slot keys by ALIAS (seatKey's fallback) while a leg that
29
+ * WAS bound keys by its seat id, so a plain `noted.has(key)` test misses and the same seat
30
+ * is announced TWICE, once per keyspace. HISTORY, not the present: at pre-PR5c HEAD the
31
+ * Workspace's alias-keyed dead-row dedup hid this by collapsing both into one row; PR5c Task 2
32
+ * seat-keyed that consumer, and T2.2 abolished the producer-side collapse — nothing masks it now.
33
+ *
34
+ * ⛔ TWO earlier attempts inferred identity here and both were rated blockers. A per-alias
35
+ * BUDGET assumed the next leg on an alias was the wave's unnamed slot. A roster PIGEONHOLE
36
+ * (`I + U + 1 > K`) replaced the assumption with arithmetic — but silently assumed `I` and `U`
37
+ * were disjoint, so when an unnamed slot and an identified leg were the same seat it overcounted
38
+ * and dropped a genuinely distinct one. Both failures were SILENT, which is the direction this
39
+ * module exists to reject.
40
+ *
41
+ * Owner ruling: stop inferring. Dedup ONLY where identity is exact, and announce otherwise.
42
+ * Exact means: the leg was bound to a seat (a real id), OR its alias holds exactly one seat in
43
+ * the roster, where the alias IS the seat id (`seats.js` mints `alias#N` only for repeats).
44
+ * Everything else is announced, because nothing proves it is a repeat.
45
+ *
46
+ * ⚠️ The accepted cost, disclosed: on a bench that repeats an alias, an unnamed wave slot and a
47
+ * leg for the same seat can each produce a note, so one dead seat may be announced twice. That
48
+ * is a VISIBLE duplicate. The alternative — the two inferences above — hid a dead seat instead,
49
+ * and a duplicate that a reader can see beats a loss they cannot.
50
+ *
51
+ * ⚠️ v4.8 T-A6 (SI-TWINS): `twins` is THREADED IN — one derivation per run, made by
52
+ * `run-retry.js :: retryStage1Losses` and handed to every producer and consumer of a
53
+ * `legLossKey`. It is NOT the same thing as `seatsPerAlias` below, and merging them would be a
54
+ * silent behaviour change: `twins` is `> 1` (an alias the roster PROVES repeated) while
55
+ * `seatsPerAlias.get(alias) === 1` is `=== 1` (an alias the roster proves UNIQUE). They differ
56
+ * on the alias the roster does not mention at all — count 0 — where `!twins.has(alias)` is true
57
+ * but `=== 1` is false. That gap is the whole point of this function's rule: with no proof of
58
+ * identity it ANNOUNCES, because a duplicate note a reader can see beats a dead seat they
59
+ * cannot. `twinAliases`' own docblock states the opposite default for the same reason.
60
+ * The parameter default exists for direct callers only; the production path always passes.
61
+ *
62
+ * @param {Array<{id: string, alias: string}>} roster the run's seat table (`o.seats`)
63
+ * @param {Map<string, number>} [twins] the run's ONE `twinAliases(roster)` (see above)
64
+ * @returns {{attempted: Set<string>, legs: Array<{leg: object, seatId: ?string}>}}
65
+ */
66
+ function planStillDeadSources(unit, seatOf, roster, twins = twinAliases(roster)) {
67
+ const seatsPerAlias = new Map();
68
+ for (const seat of roster || []) {
69
+ if (seat && seat.alias) { seatsPerAlias.set(seat.alias, (seatsPerAlias.get(seat.alias) || 0) + 1); }
70
+ }
71
+ const noted = new Set();
72
+ const attempted = new Set();
73
+ for (const w of unit.srcWaves) {
74
+ (w.models || []).forEach((m, i) => {
75
+ const k = seatKey((w.seats || [])[i] || null, m);
76
+ noted.add(k); attempted.add(k);
77
+ });
78
+ }
79
+ const legs = [];
80
+ for (const l of unit.srcLegs) {
81
+ const bound = seatOf.get(l) || null;
82
+ const alias = l.modelInput || l.model;
83
+ const key = seatKey(bound, alias);
84
+ // BOTH spellings: `key` is what an exact dead-seat row asks with, `legLossKey` is
85
+ // what an unattributable twin's row asks with (run-stage1-rows.js's finalLeg
86
+ // fallback). They are the same string whenever identity is exact, so the Set is
87
+ // unchanged on every bench but a twin one. Missing the second spelling re-attaches
88
+ // a first-attempt leg to a seat that WAS retried — and that leg already has its
89
+ // own `superseded` row, so its cost would be counted twice.
90
+ attempted.add(key);
91
+ attempted.add(legLossKey(bound, alias, l, twins));
92
+ // `key` names a specific seat only when the leg was bound, or when the alias holds exactly
93
+ // one seat (then the alias IS the id). Otherwise it is an alias standing in for "some seat",
94
+ // and matching it against a wave's unnamed slot would be a guess, not a repeat.
95
+ const identityIsExact = !!bound || seatsPerAlias.get(alias) === 1;
96
+ if (identityIsExact && noted.has(key)) { continue; }
97
+ noted.add(key);
98
+ legs.push({ leg: l, seatId: bound ? bound.id : null });
99
+ }
100
+ return { attempted, legs };
101
+ }
102
+
103
+ /**
104
+ * Dedup helper (Task-4 review hardening): the same seat can arrive twice in one
105
+ * grouping pass — two dead legs naming it, or a dead wave and a dead leg both naming
106
+ * it. One seat must still mean ONE `firstFailures` entry — first occurrence wins —
107
+ * while every SOURCE record is kept regardless (srcWaves/srcLegs are the audit trail
108
+ * and are never deduped). The critic unit's `.models` is fixed at creation (there is
109
+ * only ever one critic seat), so only bench/lens units grow `.models` here — the
110
+ * critic call sites pass `trackModel: false`.
111
+ *
112
+ * v4.8 PR2b: `seatObj` rides in lockstep, so `unit.seats` stays INDEX-PARALLEL to
113
+ * `unit.models` — same order, same length. `null` means "we could not identify this
114
+ * seat"; it is never back-filled from an alias lookup, which is exactly the guess
115
+ * seat identity exists to forbid.
116
+ *
117
+ * v4.8 T2.2: dedup ONLY where identity is EXACT — the leg was bound, or the roster
118
+ * does not repeat its alias. On a twin alias with no binding `key` names BOTH twins,
119
+ * and this early return then DISCARDED the second one BEFORE `models`/`seats` were
120
+ * ever pushed: one retry slot for two seats the run had already paid for. (PR2b H4
121
+ * claimed that collapse was correct "because nothing distinguishes them". The ROW does
122
+ * have a distinguisher — see `run-retry-keys.js :: legLossKey` — and the SLOT does not need one.)
123
+ *
124
+ * v4.8 T-A3: …and, ON THE INEXACT ARM AND PER UNIT, no further than the roster proves. There
125
+ * the alias's roster COUNT is the only upper bound there is, so mint up to it and stop: without it, N
126
+ * unattributable losses on a 2-seat alias minted N slots, each buying a real, billed retry
127
+ * leg for a seat that may not exist. (Not reachable end-to-end today — a first-pass dead
128
+ * wave carries real seats, and a wave cannot return more legs than it launched — but the
129
+ * safety rested entirely on those two facts, which nothing here stated or checked.)
130
+ * ⚠️ Scope, stated because the code cannot: this bounds SLOTS, i.e. `unit.models`, so a
131
+ * `trackModel: false` unit is unaffected — the critic's `.models` is fixed at creation
132
+ * (first paragraph above) and never grows here.
133
+ *
134
+ * ⚠️ The key is ADDED as `seatId`, and on the inexact branch it stays ALIAS-valued
135
+ * for BOTH entries. `ff.seat`/`ff.seatId` become `data.seat` /
136
+ * `data.firstFailure.seatId` on every emitted note, which verdict.js compares against
137
+ * `o.critic` (an alias) and the Workspace renders as a seat id: seat-keying `ff.seat`
138
+ * silently breaks critic-loss detection, and minting one here would put a fabricated
139
+ * seat identity on screen.
140
+ */
141
+ function recordFailure(unit, seat, ff, trackModel = true, seatObj = null, twins = null) {
142
+ const key = seatKey(seatObj, seat);
143
+ const identityIsExact = !!seatObj || !(twins && twins.has(seat));
144
+ // Exact identity: one seat is one entry. Inexact: `twins.get(seat)` is defined and >1
145
+ // exactly when this arm is taken, and it is the roster's whole claim about the alias.
146
+ const alreadyCovered = identityIsExact
147
+ ? unit.firstFailures.some(f => f.seatId === key)
148
+ : unit.models.filter(m => m === seat).length >= twins.get(seat);
149
+ if (alreadyCovered) { return; }
150
+ unit.firstFailures.push({ ...ff, seatId: key });
151
+ if (trackModel) { unit.models.push(seat); unit.seats.push(seatObj); }
152
+ }
153
+
154
+ /**
155
+ * Group Stage-1 losses into retry units. Pure — no I/O.
156
+ * Bench losses (a dead bench wave's models + dead bench legs) collapse into
157
+ * ONE retry wave; the critic and each lens retry as solos (their briefings
158
+ * differ). Stable order: bench, critic, lenses ascending. The critic matches
159
+ * on EITHER carrier — waveId convention or model — mirroring
160
+ * verdict.js summarizeSeatLoss.
161
+ *
162
+ * `seatOf` (v4.8 PR2b) is Stage-1's leg->seat binding, keyed by leg OBJECT
163
+ * identity: it is how a dead LEG contributes the seat it was actually bound to
164
+ * rather than one guessed from its alias. A wave-origin loss carries its own
165
+ * roster on `w.seats`, positionally parallel to `w.models`.
166
+ *
167
+ * `twins` (v4.8 T-A6, SI-TWINS) is THREADED IN rather than derived here — one
168
+ * `twinAliases` per run, shared by every site that mints or asks a `legLossKey`. The
169
+ * default is for direct callers (this function is exported and several suites drive it
170
+ * with four arguments); the production path always passes.
171
+ */
172
+ function groupStage1Losses(o, deadWaves = [], deadLegs = [], seatOf = new Map(),
173
+ twins = twinAliases(o.seats)) {
174
+ const isCriticWave = (w) =>
175
+ w.waveId === `${o.runId}-c1` || (!!o.critic && (w.models || []).includes(o.critic));
176
+ const bench = { unit: 'bench', waveId: `${o.runId}-s1r1`, retryOfWaveId: `${o.runId}-s1`,
177
+ models: [], seats: [], firstFailures: [], srcWaves: [], srcLegs: [] };
178
+ const lensUnits = new Map(); // lensIndex (number, or null for unmappable) -> unit
179
+ // Seeded exactly like `models`, and gated on the same `o.critic` so the two
180
+ // arrays cannot diverge in LENGTH when the critic's seat cannot be resolved
181
+ // (a caller with no o.seats): `[null]` says "unidentified", `[]` would say
182
+ // "no slot at all" and shift the retry roster.
183
+ const criticSeatObj = (o.seats || []).find(s => s.alias === o.critic) || null;
184
+ const criticUnit = { unit: 'critic', waveId: `${o.runId}-c1r1`, retryOfWaveId: `${o.runId}-c1`,
185
+ models: o.critic ? [o.critic] : [], seats: o.critic ? [criticSeatObj] : [],
186
+ firstFailures: [], srcWaves: [], srcLegs: [] };
187
+
188
+ const lensUnitFor = (i) => {
189
+ if (!lensUnits.has(i)) {
190
+ // Task-4 review hardening: an unmappable loss (lensIndexOf resolved
191
+ // neither the waveId convention nor a model-roster membership) must
192
+ // still be GROUPABLE — dropping it here would let it vanish before the
193
+ // orchestrator ever sees it — but must not manufacture a fake
194
+ // `-lnullr1` waveId. The orchestrator refuses to launch any unit with
195
+ // `lensIndex === null` and routes its sources to skipped instead.
196
+ lensUnits.set(i, i === null
197
+ ? { unit: 'lens', lensIndex: null, waveId: null, retryOfWaveId: null,
198
+ models: [], seats: [], firstFailures: [], srcWaves: [], srcLegs: [] }
199
+ : { unit: 'lens', lensIndex: i, waveId: `${o.runId}-l${i}r1`,
200
+ retryOfWaveId: `${o.runId}-l${i}`, models: [], seats: [],
201
+ firstFailures: [], srcWaves: [], srcLegs: [] });
202
+ }
203
+ return lensUnits.get(i);
204
+ };
205
+
206
+ // v4.8 PR2b Task 7 (R-B): a `partial` record is stage1-bind.js's
207
+ // missingSeatDeadWave — ONE seat of a wave that DID return legs, just not
208
+ // this seat's. It retries exactly like a dead wave, but its loss CLASS is
209
+ // 'missing': every still-dead/heal builder reads ff.class, and a 'wave'
210
+ // record makes them claim the wave "produced no legs", which is false.
211
+ // A partial critic record is stamped `-c1`, so it matches isCriticWave and
212
+ // would otherwise be recorded 'wave' on that branch alone.
213
+ const lossClass = w => (w.partial ? 'missing' : 'wave');
214
+ for (const w of deadWaves) {
215
+ const models = w.models || [];
216
+ if (o.lenses) {
217
+ const u = lensUnitFor(lensIndexOf(o, w.waveId, models[0], (w.seats || [])[0] || null));
218
+ u.srcWaves.push(w);
219
+ models.forEach((seat, idx) => recordFailure(u, seat,
220
+ { seat, class: lossClass(w), waveId: w.waveId, reason: w.reason }, true, (w.seats || [])[idx] || null, twins));
221
+ } else if (isCriticWave(w)) {
222
+ criticUnit.srcWaves.push(w);
223
+ // criticSeatObj rides even though trackModel is false: it is already
224
+ // criticUnit.seats[0], so keying the firstFailure off it is what keeps
225
+ // the dedup key and the roster slot's key the SAME string.
226
+ recordFailure(criticUnit, o.critic,
227
+ { seat: o.critic, class: lossClass(w), waveId: w.waveId, reason: w.reason }, false, criticSeatObj, twins);
228
+ } else {
229
+ bench.srcWaves.push(w);
230
+ models.forEach((seat, idx) => recordFailure(bench, seat,
231
+ { seat, class: lossClass(w), waveId: w.waveId, reason: w.reason }, true, (w.seats || [])[idx] || null, twins));
232
+ }
233
+ }
234
+ for (const leg of deadLegs) {
235
+ const seat = leg.modelInput || leg.model;
236
+ const ff = { seat, class: 'leg', status: leg.status, reason: leg.error || null };
237
+ if (o.lenses) {
238
+ const u = lensUnitFor(lensIndexOf(o, null, seat, seatOf.get(leg) || null));
239
+ u.srcLegs.push(leg);
240
+ recordFailure(u, seat, ff, true, seatOf.get(leg) || null, twins);
241
+ } else if (o.critic && seat === o.critic) {
242
+ criticUnit.srcLegs.push(leg);
243
+ recordFailure(criticUnit, seat, ff, false, criticSeatObj, twins);
244
+ } else {
245
+ bench.srcLegs.push(leg);
246
+ recordFailure(bench, seat, ff, true, seatOf.get(leg) || null, twins);
247
+ }
248
+ }
249
+
250
+ const out = [];
251
+ // Task-4 review hardening: gate on whether the unit received any SOURCE
252
+ // record, not on firstFailures.length — a zero-model dead wave contributes
253
+ // a srcWaves entry but nothing to firstFailures/models (nothing for the
254
+ // `.forEach` above to iterate), and must still surface here so the
255
+ // orchestrator can route it to skipped instead of it vanishing silently.
256
+ if (bench.srcWaves.length > 0 || bench.srcLegs.length > 0) { out.push(bench); }
257
+ if (criticUnit.srcWaves.length > 0 || criticUnit.srcLegs.length > 0) { out.push(criticUnit); }
258
+ // Coordinator-review MINOR-7a: null sorts LAST (Infinity), not first (0) —
259
+ // an unmappable loss is not "lens index 0"; it should not perturb the
260
+ // ascending order of the real, well-indexed lens retries.
261
+ out.push(...[...lensUnits.values()].sort((a, b) => (a.lensIndex ?? Infinity) - (b.lensIndex ?? Infinity)));
262
+ return out;
263
+ }
264
+
265
+ module.exports = { lensIndexOf, recordFailure, groupStage1Losses, planStillDeadSources, seatKey,
266
+ twinAliases, legLossKey, srcLegClaimer };
@@ -0,0 +1,74 @@
1
+ // src/council/run-retry-keys.js
2
+ 'use strict';
3
+ // The Stage-1 loss KEYSPACE: seatKey + twinAliases + legLossKey + srcLegClaimer.
4
+ // Moved verbatim from run-retry-group.js:23-86 AT 3d8f9d38 (v4.8 Phase 2 T-A1 size-gate
5
+ // split, zero behavior). REQUIRE-FREE by design, like ./seats and ./run-stats-entry — it is
6
+ // the leaf the loss keyspace hangs from, so no consumer can land on an import cycle.
7
+ // run-retry-group.js re-exports all four, so no import path in the tree moved.
8
+
9
+ /**
10
+ * The one seat-key rule: a seat's id when it was identified, its alias otherwise.
11
+ * Exported so `run-retry-group.js :: recordFailure` and run-retry.js both consume it
12
+ * rather than re-spelling it — two readers of one rule that drift apart is how the
13
+ * alias/seat-id keyspace splits in the first place.
14
+ */
15
+ const seatKey = (s, alias) => (s ? s.id : alias);
16
+
17
+ /**
18
+ * The aliases this run's roster proves are REPEATED, mapped to the COUNT of seats holding
19
+ * each — the only evidence that two losses on one alias are two seats and not one seat
20
+ * losing twice, and the BOUND on the slots they mint. No roster means no proof: empty Map.
21
+ *
22
+ * ⚠️ Deliberately NOT `run-retry-group.js :: planStillDeadSources`' own
23
+ * `seatsPerAlias.get(alias) === 1`, and the difference is load-bearing. That rule gates an
24
+ * ANNOUNCEMENT — being wrong costs a duplicate note a reader can see — so with no roster it
25
+ * errs toward announcing. This one gates a RETRY SLOT and a runStats row — being wrong buys
26
+ * a leg for a seat that may not exist — so with no roster it errs toward collapsing, as HEAD
27
+ * always did. ⚠️ That default is the CONSUMER's, not this filter's — with NO roster BOTH spellings
28
+ * give an empty Map. Pinned: run-retry-roster-bound.test.js :: "control: NO roster at all…".
29
+ */
30
+ function twinAliases(roster) {
31
+ const n = new Map();
32
+ for (const s of roster || []) { if (s && s.alias) { n.set(s.alias, (n.get(s.alias) || 0) + 1); } }
33
+ return new Map([...n].filter(([, c]) => c > 1));
34
+ }
35
+
36
+ /**
37
+ * A dead LEG's key where `seatKey` alone names N seats at once: an alias the roster
38
+ * repeats, on a leg no binding could identify. v4.8 T2.2 ruling R2 — MINT a
39
+ * distinguisher where one exists, and the leg arms have one: the leg's own `taskId`
40
+ * (`${waveId}-${n}`, src/sidecar/leg-ids.js:15), stamped even on a leg that never
41
+ * routed (`fanout-leg.js:61`), surviving the disk round-trip, distinct through three
42
+ * of the four ways a leg is orphaned. The fourth — NO `taskId` at all — has genuinely
43
+ * nothing and keeps the collapsing key: that is the honest floor, and inventing one
44
+ * would be the guess this keyspace exists to reject.
45
+ *
46
+ * ⚠️ INTERNAL, never rendered: it joins the dead-seat rows, `attemptedSeats` and
47
+ * `deadLegs0`, nothing else. `ff.seat`/`ff.seatId` stay ALIAS-valued — they become
48
+ * `data.seat` / `data.firstFailure.seatId`, which verdict.js compares to `o.critic`
49
+ * and the Workspace renders as a seat id.
50
+ */
51
+ function legLossKey(seatObj, alias, leg, twins) {
52
+ const key = seatKey(seatObj, alias);
53
+ if (seatObj || !twins || !twins.has(alias) || !leg || !leg.taskId) { return key; }
54
+ return `${key}\u0000${leg.taskId}`; // NUL: impossible in an alias, id or taskId
55
+ }
56
+
57
+ /**
58
+ * A STATEFUL claimer over `srcLegs` — build ONE per unit, never reuse it: each call CONSUMES
59
+ * and returns one leg whose key matches, never hands the same leg out twice, and returns null
60
+ * once that key is exhausted (`(key) => leg | null`). It replaces `srcLegs.find(...)`, which
61
+ * returned the FIRST match every time, so two unattributable twins on one alias both recorded
62
+ * the SAME source and the second seat — a leg the run paid for — vanished from stillDeadLegs.
63
+ * Which dead source pairs with which dead outcome is unknowable AND immaterial: every candidate
64
+ * is still dead after its retry, so one apiece yields the exact SET (their rows carry no seat).
65
+ */
66
+ function srcLegClaimer(srcLegs, keyOfSrc) {
67
+ const pool = new Set(srcLegs);
68
+ return (key) => {
69
+ for (const l of pool) { if (keyOfSrc(l) === key) { pool.delete(l); return l; } } // exits on the delete
70
+ return null;
71
+ };
72
+ }
73
+
74
+ module.exports = { seatKey, twinAliases, legLossKey, srcLegClaimer };
@@ -0,0 +1,55 @@
1
+ // src/council/run-retry-launch.js
2
+ 'use strict';
3
+ // The retry pass's LAUNCH side: briefingFor + bindRetryWave. Lifted out of
4
+ // run-retry.js (v4.8 Phase 2 T-A2 size-gate split, zero behavior) — briefingFor
5
+ // byte-for-byte, the roster pad/bind block as a PURE function that returns its
6
+ // bindings rather than mutating the orchestrator's accumulator.
7
+ // run-retry.js requires both back, so no existing import path moved.
8
+ // v4.8 SI-27 (ruling R14) landed: the pad/bind/drop CORE now lives in
9
+ // `stage1-bind.js :: bindPaddedWave`, shared with run-stage2.js and
10
+ // run-debate-revote.js. What stays here is retry-specific — WHICH array is the
11
+ // roster, and the orphan tail. ⚠️ `bindStage1Waves` in that same file is still a
12
+ // different contract (one call per Stage-1 wave, a real roster, no padding, no
13
+ // placeholders); `bindPaddedWave` is the one this file calls.
14
+
15
+ const briefings = require('./briefings');
16
+ const { bindPaddedWave } = require('./stage1-bind');
17
+
18
+ /** The briefing a retry unit re-issues — same builders Stage 1 used. */
19
+ function briefingFor(o, unit) {
20
+ if (unit.unit === 'critic') { return briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }); }
21
+ if (unit.unit === 'lens') {
22
+ return briefings.buildLensBriefing({ lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
23
+ }
24
+ return briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date });
25
+ }
26
+
27
+ /**
28
+ * Bind a retry wave's returned legs to the unit that launched them.
29
+ *
30
+ * PURE by design: the caller owns the accumulator. `retrySeatOf` is published into
31
+ * `out.seatOf` and the orphan legs are re-emitted with their waveId by
32
+ * `run-retry.js :: retryStage1Losses`, which also carries the rationale for why an
33
+ * orphan is REPORTED rather than noted as a degrade.
34
+ *
35
+ * @param {{seats: Array<?object>, models: Array<string>, waveId: string}} unit
36
+ * @param {Array<object>} legs the retry wave's returned legs
37
+ * @returns {{retrySeatOf: Map<object, object>, orphanLegs: Array<object>}}
38
+ */
39
+ function bindRetryWave(unit, legs) {
40
+ // The retry wave's roster IS unit.seats — run-retry-group.js :: recordFailure
41
+ // pushes models and seats in lockstep (it pushes NEITHER for the critic unit,
42
+ // whose pair is seeded off one `o.critic` gate at creation and never grows), and
43
+ // the legId `-N` suffix slot-indexes that same launch plan. `firstFailures` is a
44
+ // WEAKER case and is not read here — see run-retry.js's mint.
45
+ // A null entry means "we could not identify this seat". Why that hole is
46
+ // PADDED rather than filtered, and why the placeholders are tracked by
47
+ // identity rather than an id-name prefix test, are in the docblock of
48
+ // `stage1-bind.js :: bindPaddedWave` — which owns all three steps.
49
+ const { seatOf, bindRes } = bindPaddedWave(unit.waveId, unit.seats, i => unit.models[i], legs);
50
+ // The tail: this site hands its orphans back to the caller (run-retry.js ::
51
+ // retryStage1Losses re-emits them with their waveId).
52
+ return { retrySeatOf: seatOf, orphanLegs: bindRes.orphanLegs };
53
+ }
54
+
55
+ module.exports = { briefingFor, bindRetryWave };
@@ -17,36 +17,85 @@ const legEffect = (counts) =>
17
17
  `${counts.reviewed} of ${counts.total} seats reviewed; `
18
18
  + 'the run continues with the bench that did and will exit degraded (2)';
19
19
 
20
- /** Wave-origin, retry wave died wholesale (D5 wave granularity). */
20
+ /**
21
+ * Wave-origin, retry wave died wholesale (D5 wave granularity).
22
+ *
23
+ * v4.8 PR2b Task 7: a `partial` record (stage1-bind.js's missingSeatDeadWave) is
24
+ * ONE seat of a wave that DID return legs, so the dead-wave sentence would be a
25
+ * false statement in a user-facing degrade — it names the seat instead, on the
26
+ * `seat-unbound` channel the other half of that join failure already uses.
27
+ */
21
28
  function waveStillDeadNote(w, unit) {
22
- return { channel: 'dead-wave',
23
- what: `Stage-1 wave ${w.waveId} (${(w.models || []).join(', ') || 'no models'}) produced NO legs`,
29
+ const partial = !!w.partial;
30
+ return { channel: partial ? 'seat-unbound' : 'dead-wave',
31
+ what: partial
32
+ ? `seat ${(w.models || [])[0]} did not review`
33
+ : `Stage-1 wave ${w.waveId} (${(w.models || []).join(', ') || 'no models'}) produced NO legs`,
24
34
  // Coordinator-review MINOR-7c: a falsy w.reason must not render as the
25
35
  // literal string "undefined" in the why text.
26
36
  why: `${w.reason || 'no reason recorded'}; the once-only retry wave also produced no legs`,
27
37
  effect: 'Those seats are NOT in this council. The run continues with the bench that did '
28
38
  + 'launch and will exit degraded (2)',
29
- data: { waveId: w.waveId, models: w.models, reason: w.reason, retryWaveId: unit.waveId } };
39
+ // `seat` rides ONLY on the partial shape: adding it unconditionally breaks
40
+ // degrade-channels.test.js's exact toEqual on a real dead wave. It stays the
41
+ // ALIAS because verdict.js:72 compares data.seat against o.critic, an alias,
42
+ // and because it is the same key the dead-leg shape uses — one vocabulary
43
+ // for one field. ⚠️ It is NOT read by the Workspace today: the kind/channel filters in
44
+ // live-dead-seats.js (deadSeats) and workspace-seats.js (retriedSeats), and verdict.js's
45
+ // own two, all admit only dead-leg/dead-wave — so nothing consumes `seat-unbound` yet.
46
+ // Cited by SYMBOL, not line: these three references rotted twice during v4.8 PR5c alone.
47
+ data: { waveId: w.waveId, models: w.models, reason: w.reason, retryWaveId: unit.waveId,
48
+ // v4.8 PR5c: seat identity, index-parallel with `models`, on the dead-wave arm only
49
+ // (the partial arm names ONE seat and rides `seat` above; `seat-unbound` has no
50
+ // consumer, so an array there would be unpinned surface for no gain).
51
+ // ⚠️ An unidentified slot emits `null`, NEVER the alias. Collapsing it onto the alias
52
+ // makes it indistinguishable from a second reference to that alias, and no consumer
53
+ // can recover the difference — deadSeats has no per-alias seat count. That collapse
54
+ // is what made two distinct dead twins render as a single row.
55
+ ...(partial ? { seat: (w.models || [])[0] } : {
56
+ seats: (w.models || []).map((m, i) => {
57
+ const so = (w.seats || [])[i];
58
+ return so ? so.id : null;
59
+ }),
60
+ }) } };
30
61
  }
31
62
 
32
- /** Leg-origin, retry wave died wholesale (bench-batch case). */
33
- function srcLegStillDeadNote(leg, unit, counts) {
63
+ /**
64
+ * Leg-origin, retry wave died wholesale (bench-batch case).
65
+ *
66
+ * v4.8 PR5c: `seatId` is the caller's Stage-1 leg->seat binding, or null when the leg was
67
+ * never bound. It is a SEPARATE key from `seat`, which stays the ALIAS — verdict.js:72
68
+ * compares `data.seat` against `o.critic`, an alias, so re-pointing it breaks critic-loss
69
+ * detection. Add a key; never repurpose that one.
70
+ */
71
+ function srcLegStillDeadNote(leg, unit, counts, seatId = null) {
34
72
  const seat = leg.modelInput || leg.model;
35
73
  return { channel: 'dead-leg', what: `seat ${seat} did not review`,
36
74
  why: `the leg ended '${leg.status}'${leg.error ? `: ${leg.error}` : ''} with no usable output; `
37
75
  + 'its once-only retry wave produced no legs',
38
76
  effect: legEffect(counts),
39
- data: { seat, status: leg.status, reason: leg.error || null, retryWaveId: unit.waveId } };
77
+ data: { seat, seatId: seatId || null, status: leg.status, reason: leg.error || null,
78
+ retryWaveId: unit.waveId } };
40
79
  }
41
80
 
42
- /** Either origin, the retry produced legs but THIS seat's retry leg died. */
81
+ /**
82
+ * Either origin, the retry produced legs but THIS seat's retry leg died.
83
+ *
84
+ * A 'missing' first failure has a `reason` and a `waveId` but NEVER a `status` —
85
+ * the leg arm would report that its first leg "ended 'undefined'" for a seat
86
+ * that never had a first leg at all.
87
+ */
43
88
  function retryLegStillDeadNote(seat, ff, retryLeg, unit, counts) {
89
+ const missing = !!(ff && ff.class === 'missing');
44
90
  const why = ff && ff.class === 'wave'
45
91
  ? `its first wave ${ff.waveId} produced no legs (${ff.reason}); `
46
92
  + `its once-only retry leg ended '${retryLeg.status}' with no usable output`
47
- : `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} `
48
- + `with no usable output; its once-only retry also ended '${retryLeg.status}'`;
49
- return { channel: 'dead-leg', what: `seat ${seat} did not review`, why,
93
+ : missing
94
+ ? `${ff.reason} in wave ${ff.waveId}; its once-only retry leg ended `
95
+ + `'${retryLeg.status}' with no usable output`
96
+ : `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} `
97
+ + `with no usable output; its once-only retry also ended '${retryLeg.status}'`;
98
+ return { channel: missing ? 'seat-unbound' : 'dead-leg', what: `seat ${seat} did not review`, why,
50
99
  effect: legEffect(counts),
51
100
  data: { seat, status: retryLeg.status, reason: retryLeg.error || null,
52
101
  firstFailure: ff, retryWaveId: unit.waveId } };
@@ -62,10 +111,13 @@ function retryLegStillDeadNote(seat, ff, retryLeg, unit, counts) {
62
111
  * this time.
63
112
  */
64
113
  function missingLegStillDeadNote(seat, ff, unit, counts) {
114
+ const missing = !!(ff && ff.class === 'missing');
65
115
  const fact = ff && ff.class === 'wave'
66
116
  ? `its first wave ${ff.waveId} produced no legs (${ff.reason})`
67
- : `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} with no usable output`;
68
- return { channel: 'dead-leg', what: `seat ${seat} did not review`,
117
+ : missing
118
+ ? `${ff.reason} in wave ${ff.waveId}`
119
+ : `the leg ended '${ff ? ff.status : 'unknown'}'${ff && ff.reason ? `: ${ff.reason}` : ''} with no usable output`;
120
+ return { channel: missing ? 'seat-unbound' : 'dead-leg', what: `seat ${seat} did not review`,
69
121
  why: `${fact}; its once-only retry produced no leg for this seat`,
70
122
  effect: legEffect(counts),
71
123
  data: { seat, status: null, reason: null, firstFailure: ff, retryWaveId: unit.waveId } };