amicus 4.7.1 → 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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +882 -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 +2 -2
  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/setup-ui-aliases.js +2 -2
  14. package/electron/workspace-ui/index.html +9 -0
  15. package/electron/workspace-ui/live-dead-seats.js +228 -0
  16. package/electron/workspace-ui/live-model.js +10 -236
  17. package/electron/workspace-ui/live-seats.js +126 -0
  18. package/electron/workspace-ui/workspace-app.js +6 -41
  19. package/electron/workspace-ui/workspace-banners.js +95 -0
  20. package/electron/workspace-ui/workspace-lazy.js +55 -12
  21. package/electron/workspace-ui/workspace-matrix.js +2 -2
  22. package/electron/workspace-ui/workspace-panels.js +42 -10
  23. package/electron/workspace-ui/workspace-render.js +2 -2
  24. package/electron/workspace-ui/workspace-seats.js +101 -17
  25. package/package.json +3 -1
  26. package/schemas/council-run-live.schema.json +1 -0
  27. package/schemas/council-run.schema.json +19 -0
  28. package/schemas/council-tally.schema.json +34 -2
  29. package/schemas/council-verdict.schema.json +15 -0
  30. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  31. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  32. package/skills/second-opinion/SKILL.md +25 -11
  33. package/src/cli-handlers-doctor.js +7 -0
  34. package/src/council/anonymize.js +80 -11
  35. package/src/council/briefings-chair.js +272 -0
  36. package/src/council/briefings-stage2.js +12 -140
  37. package/src/council/debate.js +120 -15
  38. package/src/council/ledger-join.js +284 -0
  39. package/src/council/ledger-stats.js +100 -0
  40. package/src/council/ledger.js +177 -88
  41. package/src/council/peer-split.js +196 -0
  42. package/src/council/report-html.js +12 -5
  43. package/src/council/report-md.js +146 -0
  44. package/src/council/report.js +188 -112
  45. package/src/council/run-assemble.js +100 -79
  46. package/src/council/run-chair.js +17 -1
  47. package/src/council/run-debate-revote.js +268 -0
  48. package/src/council/run-debate.js +92 -102
  49. package/src/council/run-finish.js +70 -0
  50. package/src/council/run-launch.js +45 -18
  51. package/src/council/run-retry-group.js +266 -0
  52. package/src/council/run-retry-keys.js +74 -0
  53. package/src/council/run-retry-launch.js +55 -0
  54. package/src/council/run-retry-notes.js +65 -13
  55. package/src/council/run-retry.js +166 -161
  56. package/src/council/run-stage1-launch.js +27 -10
  57. package/src/council/run-stage1-rows.js +220 -0
  58. package/src/council/run-stage1-superseded.js +156 -0
  59. package/src/council/run-stage2.js +65 -5
  60. package/src/council/run-stages.js +72 -69
  61. package/src/council/run-state.js +1 -1
  62. package/src/council/run-stats-entry.js +71 -0
  63. package/src/council/run-verdict-files.js +52 -0
  64. package/src/council/run.js +42 -52
  65. package/src/council/seats.js +262 -0
  66. package/src/council/stage1-bind.js +142 -0
  67. package/src/council/street-cred.js +258 -0
  68. package/src/council/tally.js +100 -49
  69. package/src/council/verdict.js +49 -1
  70. package/src/headless.js +20 -6
  71. package/src/mcp-tools.js +71 -1
  72. package/src/observe/council-legs.js +7 -1
  73. package/src/observe/live-doc.js +3 -3
  74. package/src/sidecar/fanout-leg.js +22 -1
  75. package/src/sidecar/fanout-wave-io.js +26 -1
  76. package/src/sidecar/fanout.js +4 -10
  77. package/src/sidecar/leg-ids.js +19 -0
  78. package/src/sidecar/models-probe.js +7 -4
  79. package/src/sidecar/reopen-spend.js +1 -1
  80. package/src/sidecar/setup.js +13 -2
  81. package/src/utils/config.js +109 -11
  82. package/src/utils/curated-models.js +17 -2
  83. package/src/utils/degrade.js +5 -0
  84. package/src/utils/no-output-backstop.js +1 -1
  85. package/src/utils/remediation-hints.js +13 -0
  86. package/src/utils/session-index-prune.js +297 -0
  87. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  88. package/src/workspace/artifact-guard.js +8 -114
  89. package/src/workspace/artifact-names.js +222 -0
  90. package/src/workspace/fold-format.js +9 -6
  91. package/src/workspace/live-normalize.js +6 -2
  92. package/src/workspace/matrix-model.js +141 -19
  93. package/src/workspace/run-detail.js +27 -1
  94. package/src/workspace/seat-space.js +143 -0
@@ -14,153 +14,64 @@
14
14
  * only.
15
15
  */
16
16
 
17
- const briefings = require('./briefings');
18
17
  const { materializeReviews, isAbortExit } = require('./run-launch');
19
18
  const runState = require('./run-state');
20
19
  const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
21
20
  const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
22
21
  = require('./run-retry-notes');
23
-
24
- /** 1-based lens index for a loss, from the waveId convention or the model. */
25
- function lensIndexOf(o, waveId, model) {
26
- const m = /-l(\d+)$/.exec(waveId || '');
27
- if (m) { return Number(m[1]); }
28
- const i = (o.models || []).indexOf(model);
29
- return i === -1 ? null : i + 1;
30
- }
31
-
32
- /**
33
- * Dedup helper (Task-4 review hardening): the same seat can arrive twice in
34
- * one grouping pass — two dead legs naming it, or a dead wave and a dead leg
35
- * both naming it. One seat must still mean ONE `firstFailures` entry — first
36
- * occurrence wins — while every SOURCE record is kept regardless (srcWaves/
37
- * srcLegs are the audit trail and are never deduped). The critic unit's
38
- * `.models` is fixed at creation (there is only ever one critic seat), so
39
- * only bench/lens units grow `.models` here — the critic call sites pass
40
- * `trackModel: false`.
41
- */
42
- function recordFailure(unit, seat, ff, trackModel = true) {
43
- if (unit.firstFailures.some(f => f.seat === seat)) { return; }
44
- unit.firstFailures.push(ff);
45
- if (trackModel) { unit.models.push(seat); }
46
- }
47
-
48
- /**
49
- * Group Stage-1 losses into retry units. Pure — no I/O.
50
- * Bench losses (a dead bench wave's models + dead bench legs) collapse into
51
- * ONE retry wave; the critic and each lens retry as solos (their briefings
52
- * differ). Stable order: bench, critic, lenses ascending. The critic matches
53
- * on EITHER carrier — waveId convention or model — mirroring
54
- * verdict.js summarizeSeatLoss.
55
- */
56
- function groupStage1Losses(o, deadWaves = [], deadLegs = []) {
57
- const isCriticWave = (w) =>
58
- w.waveId === `${o.runId}-c1` || (!!o.critic && (w.models || []).includes(o.critic));
59
- const bench = { unit: 'bench', waveId: `${o.runId}-s1r1`, retryOfWaveId: `${o.runId}-s1`,
60
- models: [], firstFailures: [], srcWaves: [], srcLegs: [] };
61
- const lensUnits = new Map(); // lensIndex (number, or null for unmappable) -> unit
62
- const criticUnit = { unit: 'critic', waveId: `${o.runId}-c1r1`, retryOfWaveId: `${o.runId}-c1`,
63
- models: o.critic ? [o.critic] : [], firstFailures: [], srcWaves: [], srcLegs: [] };
64
-
65
- const lensUnitFor = (i) => {
66
- if (!lensUnits.has(i)) {
67
- // Task-4 review hardening: an unmappable loss (lensIndexOf resolved
68
- // neither the waveId convention nor a model-roster membership) must
69
- // still be GROUPABLE — dropping it here would let it vanish before the
70
- // orchestrator ever sees it — but must not manufacture a fake
71
- // `-lnullr1` waveId. The orchestrator refuses to launch any unit with
72
- // `lensIndex === null` and routes its sources to skipped instead.
73
- lensUnits.set(i, i === null
74
- ? { unit: 'lens', lensIndex: null, waveId: null, retryOfWaveId: null,
75
- models: [], firstFailures: [], srcWaves: [], srcLegs: [] }
76
- : { unit: 'lens', lensIndex: i, waveId: `${o.runId}-l${i}r1`,
77
- retryOfWaveId: `${o.runId}-l${i}`, models: [], firstFailures: [], srcWaves: [], srcLegs: [] });
78
- }
79
- return lensUnits.get(i);
80
- };
81
-
82
- for (const w of deadWaves) {
83
- const models = w.models || [];
84
- if (o.lenses) {
85
- const u = lensUnitFor(lensIndexOf(o, w.waveId, models[0]));
86
- u.srcWaves.push(w);
87
- models.forEach(seat => recordFailure(u, seat, { seat, class: 'wave', waveId: w.waveId, reason: w.reason }));
88
- } else if (isCriticWave(w)) {
89
- criticUnit.srcWaves.push(w);
90
- recordFailure(criticUnit, o.critic,
91
- { seat: o.critic, class: 'wave', waveId: w.waveId, reason: w.reason }, false);
92
- } else {
93
- bench.srcWaves.push(w);
94
- models.forEach(seat => recordFailure(bench, seat, { seat, class: 'wave', waveId: w.waveId, reason: w.reason }));
95
- }
96
- }
97
- for (const leg of deadLegs) {
98
- const seat = leg.modelInput || leg.model;
99
- const ff = { seat, class: 'leg', status: leg.status, reason: leg.error || null };
100
- if (o.lenses) {
101
- const u = lensUnitFor(lensIndexOf(o, null, seat));
102
- u.srcLegs.push(leg);
103
- recordFailure(u, seat, ff);
104
- } else if (o.critic && seat === o.critic) {
105
- criticUnit.srcLegs.push(leg);
106
- recordFailure(criticUnit, seat, ff, false);
107
- } else {
108
- bench.srcLegs.push(leg);
109
- recordFailure(bench, seat, ff);
110
- }
111
- }
112
-
113
- const out = [];
114
- // Task-4 review hardening: gate on whether the unit received any SOURCE
115
- // record, not on firstFailures.length — a zero-model dead wave contributes
116
- // a srcWaves entry but nothing to firstFailures/models (nothing for the
117
- // `.forEach` above to iterate), and must still surface here so the
118
- // orchestrator can route it to skipped instead of it vanishing silently.
119
- if (bench.srcWaves.length > 0 || bench.srcLegs.length > 0) { out.push(bench); }
120
- if (criticUnit.srcWaves.length > 0 || criticUnit.srcLegs.length > 0) { out.push(criticUnit); }
121
- // Coordinator-review MINOR-7a: null sorts LAST (Infinity), not first (0) —
122
- // an unmappable loss is not "lens index 0"; it should not perturb the
123
- // ascending order of the real, well-indexed lens retries.
124
- out.push(...[...lensUnits.values()].sort((a, b) => (a.lensIndex ?? Infinity) - (b.lensIndex ?? Infinity)));
125
- return out;
126
- }
127
-
128
- /** The briefing a retry unit re-issues — same builders Stage 1 used. */
129
- function briefingFor(o, unit) {
130
- if (unit.unit === 'critic') { return briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }); }
131
- if (unit.unit === 'lens') {
132
- return briefings.buildLensBriefing({ lens: o.lenses[unit.lensIndex - 1], briefing: o.briefing, date: o.date });
133
- }
134
- return briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date });
135
- }
22
+ // briefingFor + bindRetryWave live in ./run-retry-launch (v4.8 T-A2 split); the pad/bind core it wraps is stage1-bind.js :: bindPaddedWave (SI-27).
23
+ const { briefingFor, bindRetryWave } = require('./run-retry-launch');
24
+ // Loss grouping lives in ./run-retry-group (v4.8 PR0 size-gate split).
25
+ // groupStage1Losses is re-exported below — run-retry.test.js imports it
26
+ // from here.
27
+ const { groupStage1Losses, planStillDeadSources, seatKey, twinAliases, legLossKey,
28
+ srcLegClaimer } = require('./run-retry-group');
136
29
 
137
30
  /**
138
31
  * The retry pass. Serial by design (spec D-order: bench, critic, lenses) —
139
32
  * the per-wave-fallback path, where waves actually die, is exactly where
140
33
  * concurrent relaunches would race the same server start again.
141
34
  */
142
- async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts = { reviewed: 0, total: 0 } } = {}) {
35
+ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [],
36
+ counts = { reviewed: 0, total: 0 }, seatOf = new Map() } = {}) {
143
37
  const { o, launchers } = ctx;
144
- const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
38
+ // v4.8 T-A6 (SI-TWINS): THE run's one `twinAliases`. It was derived independently at four
39
+ // sites across three files; now it is made here and threaded to all of them — the two
40
+ // run-retry-group.js consumers below by argument, and run-stage1-rows.js through `out.twins`,
41
+ // which run-stages.js hands straight to `pushDeadSeatRows`. Two collections that disagree is
42
+ // the desync mutants DESYNCLEG and DESYNCPLAN pin: a retried twin re-acquires its own FIRST
43
+ // leg, which already carries a `superseded` row, so that leg is billed twice.
44
+ const twins = twinAliases(o.seats); // the roster's repeated aliases (v4.8 T2.2)
45
+ // `seatOf`/`orphanLegs` are part of the CONTRACT, not a debugging aid: a
46
+ // recovered leg is a RETRY-wave object Stage-1's seatOf has never seen, so
47
+ // without publishing these bindings every healed seat re-materializes
48
+ // unattributed downstream (spec §4.4).
49
+ // `attemptedSeats` (v4.8 PR2b Task 8): the seat-keyed "was this seat actually
50
+ // retried?" gate. Structured data, never a scan of stillDeadNotes — the full
51
+ // rationale sits at run-stage1-rows.js's fallback, its only consumer.
52
+ // `twins` rides on the return for the same reason `seatOf` does: the consumer must ask with
53
+ // the collection this pass minted with, not one it re-derives (v4.8 T-A6).
54
+ const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [], twins,
145
55
  stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
146
- stillDeadRetryLegs: [] };
56
+ stillDeadRetryLegs: [], seatOf: new Map(), orphanLegs: [], attemptedSeats: new Set() };
147
57
  // Task 5 (#129): SL-2 retries the SAME model under the SAME conditions, so a
148
58
  // latency failure is structurally unhealable. Double the window, clamped to
149
59
  // the leg timeout so the failure CLASS stays NO_OUTPUT_BACKSTOP rather than
150
60
  // silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
151
61
  // the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
62
+ // ⚠️ #135 C0 took this 240s -> 600s; deliberate, see CHANGELOG (council A1, PR #182).
152
63
  const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
153
64
  const escalatedBackstopMs = Math.min(
154
65
  2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs()),
155
66
  legTimeoutMs,
156
67
  );
157
68
 
158
- for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
69
+ for (const unit of groupStage1Losses(o, deadWaves, deadLegs, seatOf, twins)) {
159
70
  // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
160
71
  // unmappable lens loss (no carrier resolved an index), a lens index
161
72
  // outside the run's actual lens roster (coordinator-review MINOR-7b: a
162
73
  // malformed waveId like "...-l99" must not become an out-of-range
163
- // `o.lenses[98]` access inside briefingFor), or a unit whose sources
74
+ // `o.lenses[98]` access inside run-retry-launch.js's briefingFor), or a unit whose sources
164
75
  // named zero models — is never launched. Its sources fall back to the
165
76
  // ordinary skipped-loss path so the caller's normal degrade notes still
166
77
  // fire; being unmappable is not an exemption from the record.
@@ -196,6 +107,56 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
196
107
  if (isAbortExit(res.exitCode)) { out.aborted = res.exitCode; return out; }
197
108
 
198
109
  const legs = (res.wave && Array.isArray(res.wave.legs)) ? res.wave.legs : [];
110
+ const { retrySeatOf, orphanLegs } = bindRetryWave(unit, legs);
111
+ for (const [l, s] of retrySeatOf) { out.seatOf.set(l, s); }
112
+ // A retry leg that matches no roster slot is the same attribution failure
113
+ // Stage-1 announces — but this module emits heals ONLY and never notes a
114
+ // degrade (see the @module docblock), so it is REPORTED here and emitted by
115
+ // the caller, exactly as stillDeadNotes already are.
116
+ for (const leg of orphanLegs) { out.orphanLegs.push({ waveId: unit.waveId, leg }); }
117
+ // Every seat this unit LAUNCHED for, keyed exactly the way recordFailure
118
+ // keyed it: the identified seat's id, else the alias.
119
+ // ⚠️ v4.8 T-A4: a key is NOT one slot. Since T2.2 two UNATTRIBUTABLE twins on one alias
120
+ // mint two slots that both key by that alias, and a first-wins entry collapsed them —
121
+ // announcing ONE dead seat where two died (B1) while every note built from it read slot
122
+ // 0's firstFailure (B2). So an entry carries a slot COUNT and its OWN per-slot
123
+ // firstFailures. Only `unit.models` — the launch PLAN — mints: srcWaves/srcLegs are the
124
+ // union safety net the reconcile below rests on, and must leave a key at >=1 without
125
+ // inflating one that arrives through both. Inflating it manufactures a phantom dead seat.
126
+ // ⚠️ v4.8 H4: an alias is no longer a seat identity. A `new Map(unit.models
127
+ // .map(...))` lookup overwrites the duplicate key, so a twin bench resolves
128
+ // BOTH still-lost seats to the LAST twin — an affirmative mis-attribution,
129
+ // strictly worse than the `null` that says "unidentified". Every feeder
130
+ // below must be seat-keyed for the same reason: a MIX (['x#1','x#2','x'])
131
+ // can never match `seenSeats`, so a run where both twins healed would emit
132
+ // a phantom dead-leg degrade and exit 2.
133
+ const launched = new Map(); // key -> {alias, seat, slots, ffs}; alias is what notes render
134
+ const addLaunched = (s, alias, ff, mint = false) => {
135
+ const k = seatKey(s, alias);
136
+ if (!launched.has(k)) { launched.set(k, { alias, seat: s || null, slots: 0, ffs: [] }); }
137
+ if (mint) { const rec = launched.get(k); rec.slots += 1; rec.ffs.push(ff || null); }
138
+ };
139
+ // NOT lockstep by construction — recordFailure pushes `firstFailures` always but models/seats
140
+ // only `if (trackModel)`. Slot i is one seat's whole record EMERGENTLY, on two measured facts:
141
+ // no unit MIXES the flag (bench/lens sites pass true, both critic sites false), so a tracking
142
+ // unit grows all three together; and the critic unit's `firstFailures` caps at 1 — its INEXACT
143
+ // arm needs `twins.has(o.critic)` with a null `criticSeatObj`, and `twins` IS `twinAliases(o.seats)`,
144
+ // so the first implies the second non-null. The one MEASURED break (a `-c1` wave, no `o.critic`:
145
+ // ff 1 / models 0) the `models.length === 0` skip above ate. Pinned: run-retry-lockstep.test.js.
146
+ unit.models.forEach((m, i) => addLaunched(unit.seats[i] || null, m, unit.firstFailures[i], true));
147
+ for (const w of unit.srcWaves) {
148
+ (w.models || []).forEach((m, i) => addLaunched((w.seats || [])[i] || null, m));
149
+ }
150
+ for (const l of unit.srcLegs) { addLaunched(seatOf.get(l) || null, l.modelInput || l.model); }
151
+ // A Stage-1 source leg's key, from the binding Stage 1 published for it.
152
+ const srcLegKey = l => seatKey(seatOf.get(l) || null, l.modelInput || l.model);
153
+ // v4.8 T2.2: ONE source per still-dead outcome (srcLegs.find handed the first one
154
+ // out twice, so the second orphaned twin left no record), and the ROW key that seat
155
+ // will be looked up by downstream. Both move with recordFailure's dedup or a retried
156
+ // twin re-acquires its own first leg — which already has a `superseded` row, so that
157
+ // leg's cost lands in runStats twice while its retry leg lands nowhere.
158
+ const claimSrc = srcLegClaimer(unit.srcLegs, srcLegKey);
159
+ const srcRowKey = l => legLossKey(seatOf.get(l) || null, l.modelInput || l.model, l, twins);
199
160
  if (legs.length === 0) {
200
161
  // The retry wave itself died wholesale — final failure keeps each
201
162
  // source's granularity (D5): wave-origin stays a dead-wave, leg-origin
@@ -207,32 +168,46 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
207
168
  // the "wave wins" precedent from the grouping-level dedup); a seat
208
169
  // already covered by its wave's note is skipped when its srcLeg is
209
170
  // reached.
210
- const notedSeats = new Set();
171
+ // v4.8 PR5c: the srcWave/srcLeg dedup lives in run-retry-group.planStillDeadSources —
172
+ // an unidentified wave slot and a bound leg for the same seat key in DIFFERENT
173
+ // spaces, so a naive test announces one seat twice. See that helper for the budget.
174
+ const plan = planStillDeadSources(unit, seatOf, o.seats, twins);
211
175
  for (const w of unit.srcWaves) {
212
176
  out.stillDeadNotes.push(waveStillDeadNote(w, unit));
213
177
  out.stillDeadWaves.push(w);
214
- for (const m of (w.models || [])) { notedSeats.add(m); }
215
178
  }
216
- for (const l of unit.srcLegs) {
217
- const seat = l.modelInput || l.model;
218
- if (notedSeats.has(seat)) { continue; }
219
- notedSeats.add(seat);
220
- out.stillDeadNotes.push(srcLegStillDeadNote(l, unit, counts));
221
- out.stillDeadLegs.push(l);
179
+ for (const k of plan.attempted) { out.attemptedSeats.add(k); }
180
+ for (const { leg, seatId } of plan.legs) {
181
+ out.stillDeadNotes.push(srcLegStillDeadNote(leg, unit, counts, seatId));
182
+ out.stillDeadLegs.push(leg);
222
183
  }
223
184
  continue;
224
185
  }
225
- const usable = new Set(materializeReviews(o.runDir, legs).map(m => m.leg));
226
- const seenSeats = new Set(legs.map(leg => leg.modelInput || leg.model));
227
- const lostWaveSeats = new Map(); // waveId -> seats still lost from a wave-origin
186
+ const usable = new Set(materializeReviews(o.runDir, legs, retrySeatOf).map(m => m.leg));
187
+ // Seat-keyed off THIS wave's own bindings (an unbound leg keys by alias,
188
+ // matching how its unidentified roster slot was keyed above).
189
+ // v4.8 T-A4: a COUNT, not a presence Set — two unattributable twins share one key, so
190
+ // "was it seen at all?" cannot tell one returned leg from two. Filled by the loop below,
191
+ // which visits every leg (including one skipped for having no slot, as `legs.map` did).
192
+ const seenSeats = new Map(); // key -> legs this wave returned for it
193
+ const lostWaveSeats = new Map(); // waveId -> [{alias, seat}] still lost from a wave-origin
228
194
  for (const leg of legs) {
229
- const seat = leg.modelInput || leg.model;
230
- const ff = unit.firstFailures.find(f => f.seat === seat) || null;
195
+ const seat = leg.modelInput || leg.model; // ALIAS — what every note renders
196
+ const bound = retrySeatOf.get(leg) || null;
197
+ const key = seatKey(bound, seat);
198
+ // This leg answers the key's NEXT unanswered slot, and reads THAT slot's own
199
+ // firstFailure (T-A4 B2). Counted BEFORE the `!ff` skip below, so the tally the
200
+ // reconcile reads still means "legs this wave returned for the key".
201
+ const slot = seenSeats.get(key) || 0;
202
+ seenSeats.set(key, slot + 1);
203
+ const ff = (launched.get(key) || { ffs: [] }).ffs[slot] || null;
231
204
  // SL-2 fix-wave: a retry response should only ever name seats THIS unit
232
- // launched for (unit.models is built in lockstep with firstFailures via
233
- // groupStage1Losses's recordFailure) — but if a leg turns up for a seat
234
- // with no firstFailures entry, that seat never lost its seat in the
235
- // first place. Skip it entirely: no heal (it would fabricate an "ended
205
+ // launched for (`unit.models` is index-parallel to `firstFailures` EMERGENTLY,
206
+ // for the two reasons the mint above states) — but if a leg turns up for a seat
207
+ // with no firstFailures entry, or (v4.8 T-A4) beyond the LAST slot its key
208
+ // minted, that seat never lost its seat in the first place. `slots` is an
209
+ // upper bound as well as a lower one, and this is where the upper half is
210
+ // spent. Skip it entirely: no heal (it would fabricate an "ended
236
211
  // 'unknown'" why for a seat that never failed) and no still-dead note —
237
212
  // the seat's first-attempt review stands untouched, rather than this
238
213
  // stray leg doubling it into a duplicate bench entry alongside the real one.
@@ -241,20 +216,32 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
241
216
  out.recoveredLegs.push(leg);
242
217
  ctx.degrade.note({ channel: 'stage1-retry', kind: 'heal',
243
218
  what: `seat ${seat} reviewed on retry`,
244
- why: ff && ff.class === 'wave'
219
+ // A 'missing' ff (a seat whose leg never came back) has a reason and a
220
+ // waveId but NEVER a status — the leg arm would render "ended
221
+ // 'undefined'" for a seat that never had a leg at all.
222
+ why: ff.class === 'wave'
245
223
  ? `its first wave ${ff.waveId} produced no legs (${ff.reason}) and was relaunched once`
246
- : `its first leg ended '${ff ? ff.status : 'unknown'}' with no usable output and was relaunched once`,
224
+ : ff.class === 'missing'
225
+ ? `${ff.reason} in wave ${ff.waveId}, and it was relaunched once`
226
+ : `its first leg ended '${ff ? ff.status : 'unknown'}' with no usable output and was relaunched once`,
247
227
  effect: 'The seat is in this council; nothing was lost',
248
228
  data: { seat, retryWaveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, firstFailure: ff } });
249
229
  } else {
250
230
  out.stillDeadNotes.push(retryLegStillDeadNote(seat, ff, leg, unit, counts));
231
+ out.attemptedSeats.add(key);
251
232
  out.stillDeadRetryLegs.push(leg);
252
- if (ff && ff.class === 'wave') {
233
+ // Both WAVE-origin classes route here: a 'missing' loss carries a
234
+ // waveId and no srcLeg, so the else branch would drop it from every
235
+ // array. Task-6 carry: an UNBINDABLE retry leg leaves `bound` null
236
+ // where the roster slot was known — fall back to the launched record
237
+ // rather than publishing seats:[null]. Never a guess: this line is
238
+ // reachable only when `key` is already in `launched`.
239
+ if (ff.class !== 'leg') {
253
240
  if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
254
- lostWaveSeats.get(ff.waveId).push(seat);
241
+ lostWaveSeats.get(ff.waveId).push({ alias: seat, seat: bound || (launched.get(key) || {}).seat || null });
255
242
  } else {
256
- const src = unit.srcLegs.find(l => (l.modelInput || l.model) === seat);
257
- if (src) { out.stillDeadLegs.push(src); }
243
+ const src = claimSrc(key);
244
+ if (src) { out.stillDeadLegs.push(src); out.attemptedSeats.add(srcRowKey(src)); }
258
245
  }
259
246
  }
260
247
  }
@@ -262,31 +249,49 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
262
249
  // that came back WITH a leg record. A partial wave return (unit models
263
250
  // [a,b], the wave comes back with only a's leg) leaves 'b' invisible to
264
251
  // that loop entirely — no heal, no still-dead note, no skip: it would
265
- // vanish from every array. Reconcile against the full launched-seat set
266
- // (the union of unit.models, every srcWave's models, and every srcLeg's
267
- // seat — not just unit.models alone, so this holds even if some future
268
- // change to the grouping made unit.models an incomplete union) so every
269
- // launched seat lands in exactly one of recovered / still-dead / skipped.
270
- const launchedSeats = new Set(unit.models);
271
- for (const w of unit.srcWaves) { for (const m of (w.models || [])) { launchedSeats.add(m); } }
272
- for (const l of unit.srcLegs) { launchedSeats.add(l.modelInput || l.model); }
273
- for (const seat of launchedSeats) {
274
- if (seenSeats.has(seat)) { continue; } // already handled above (healed or still-dead)
275
- const ff = unit.firstFailures.find(f => f.seat === seat) || null;
276
- out.stillDeadNotes.push(missingLegStillDeadNote(seat, ff, unit, counts));
277
- if (ff && ff.class === 'wave') {
278
- if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
279
- lostWaveSeats.get(ff.waveId).push(seat);
280
- } else {
281
- const src = unit.srcLegs.find(l => (l.modelInput || l.model) === seat);
282
- if (src) { out.stillDeadLegs.push(src); }
252
+ // vanish from every array. Reconcile against `launched` — the union of
253
+ // unit.models, every srcWave's models and every srcLeg's seat, not
254
+ // unit.models alone, so this holds even if some future change to the
255
+ // grouping made unit.models an incomplete union so every launched SLOT
256
+ // lands in exactly one of recovered / still-dead / skipped. Slot-indexed:
257
+ // there is no leg here to read a binding off, so the seat comes from the
258
+ // roster slot itself, never from an alias lookup.
259
+ for (const [key, rec] of launched) {
260
+ // v4.8 T-A4: `max(slots, 1) - seen` notes, not a presence test. `max(…, 1)` is what
261
+ // keeps the union net a net: feeders 2 and 3 mint no slot, so a key only THEY know
262
+ // still reconciles exactly ONCE, and a seat that arrives through both the launch plan
263
+ // and a srcLeg is counted by the plan alone. `rec.seat` is shared across a key's slots
264
+ // and that is sound: two slots can only share a key when NEITHER was identified (an
265
+ // identified slot keys by its own seat id), so every slot of such a key has seat null.
266
+ for (let i = seenSeats.get(key) || 0; i < Math.max(rec.slots, 1); i++) {
267
+ const ff = rec.ffs[i] || null;
268
+ out.stillDeadNotes.push(missingLegStillDeadNote(rec.alias, ff, unit, counts));
269
+ out.attemptedSeats.add(key);
270
+ if (ff && ff.class !== 'leg') { // wave-origin, incl. a 'missing' seat
271
+ if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
272
+ lostWaveSeats.get(ff.waveId).push({ alias: rec.alias, seat: rec.seat });
273
+ } else {
274
+ const src = claimSrc(key);
275
+ if (src) { out.stillDeadLegs.push(src); out.attemptedSeats.add(srcRowKey(src)); }
276
+ }
283
277
  }
284
278
  }
285
279
  // Wave-origin seats still lost: the return-contract wave entry carries only
286
280
  // the still-lost subset (a partially healed wave is not wholly dead).
281
+ // `seats` is narrowed in LOCKSTEP with `models`, or run-stage1-rows.js's
282
+ // dead-seat loop index-zips a healed seat against a lost seat's alias.
283
+ // Since v4.8 a wave can contribute SEVERAL srcWave records sharing one
284
+ // waveId (one per missing seat, stage1-bind.js's missingSeatDeadWave), so
285
+ // the lookup fires once per waveId — not once per record, which would push
286
+ // every still-lost seat of that wave N times.
287
+ const reconciled = new Set();
287
288
  for (const w of unit.srcWaves) {
289
+ if (reconciled.has(w.waveId)) { continue; }
290
+ reconciled.add(w.waveId);
288
291
  const lost = lostWaveSeats.get(w.waveId) || [];
289
- if (lost.length > 0) { out.stillDeadWaves.push({ ...w, models: lost }); }
292
+ if (lost.length > 0) {
293
+ out.stillDeadWaves.push({ ...w, models: lost.map(x => x.alias), seats: lost.map(x => x.seat) });
294
+ }
290
295
  }
291
296
  }
292
297
  return out;
@@ -9,10 +9,17 @@
9
9
  const briefings = require('./briefings');
10
10
  const runState = require('./run-state');
11
11
  const { isAbortExit } = require('./run-launch');
12
+ const { buildSeats } = require('./seats');
12
13
 
13
14
  /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
14
15
  async function launchStage1(ctx) {
15
16
  const { o, launchers } = ctx;
17
+ // Seat identity for THIS launch. run.js:133 sets o.seats from asm.preflightSeats;
18
+ // buildSeats is pure and total, so a direct require() caller or a legacy run dir
19
+ // reconstructs the same table rather than binding nothing (spec §4.3).
20
+ const seats = Array.isArray(o.seats) && o.seats.length > 0
21
+ ? o.seats
22
+ : buildSeats(o.models, o.critic, o.lenses);
16
23
  // `noCostGate` rides EVERY launch object in this file (here, the findings
17
24
  // repair, the judge wave, the judge repair) — see run-launch.js's fanout call.
18
25
  const common = {
@@ -37,27 +44,32 @@ async function launchStage1(ctx) {
37
44
  o.models.forEach((m, i) => {
38
45
  const waveId = `${o.runId}-l${i + 1}`;
39
46
  record(waveId);
40
- seated.push({ waveId, models: [m] });
47
+ seated.push({ waveId, models: [m], roster: seats.slice(i, i + 1) });
41
48
  launches.push(launchers.launchSolo({
42
- ...common, model: m, waveId,
49
+ ...common, model: m, waveId, seats: seated[seated.length - 1].roster,
43
50
  prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
44
51
  }));
45
52
  });
46
53
  } else {
47
- const seats = o.models.filter(m => m !== o.critic);
48
- if (seats.length > 0) {
54
+ const seats1 = o.models.filter(m => m !== o.critic);
55
+ if (seats1.length > 0) {
49
56
  record(`${o.runId}-s1`);
50
- seated.push({ waveId: `${o.runId}-s1`, models: seats.slice() });
57
+ // MUST mirror :54's `m !== o.critic` exactly. Filtering on `s.id !== o.criticSeat`
58
+ // instead would drop ONE twin where the alias filter drops BOTH, shifting every
59
+ // legId slot by one on a bench preflightSeats never saw.
60
+ seated.push({ waveId: `${o.runId}-s1`, models: seats1.slice(),
61
+ roster: seats.filter(s => s.alias !== o.critic) });
51
62
  launches.push(launchers.launchWave({
52
- ...common, models: seats, waveId: `${o.runId}-s1`,
63
+ ...common, models: seats1, waveId: `${o.runId}-s1`, seats: seated[seated.length - 1].roster,
53
64
  prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
54
65
  }));
55
66
  }
56
67
  if (o.critic) {
57
68
  record(`${o.runId}-c1`);
58
- seated.push({ waveId: `${o.runId}-c1`, models: [o.critic] });
69
+ seated.push({ waveId: `${o.runId}-c1`, models: [o.critic],
70
+ roster: seats.filter(s => s.alias === o.critic).slice(0, 1) });
59
71
  launches.push(launchers.launchSolo({
60
- ...common, model: o.critic, waveId: `${o.runId}-c1`,
72
+ ...common, model: o.critic, waveId: `${o.runId}-c1`, seats: seated[seated.length - 1].roster,
61
73
  prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
62
74
  }));
63
75
  }
@@ -66,12 +78,17 @@ async function launchStage1(ctx) {
66
78
  let aborted = null;
67
79
  const legs = [];
68
80
  const deadWaves = [];
81
+ const waves = [];
69
82
  results.forEach((r, i) => {
70
83
  ctx.addWave(r.wave);
71
84
  const abort = isAbortExit(r.exitCode);
72
85
  if (abort) { aborted = r.exitCode; }
73
86
  const got = (r.wave && Array.isArray(r.wave.legs)) ? r.wave.legs : [];
74
87
  legs.push(...got);
88
+ // Per-wave partition, captured BEFORE the flatten above erases attribution.
89
+ // bindSeats is called once per entry by the consumer; a wave that produced
90
+ // nothing still gets an entry so its roster is never lost.
91
+ waves.push({ waveId: seated[i].waveId, roster: seated[i].roster, legs: got });
75
92
  // ⚠️ Step 10's uncovered half. A wave that died BEFORE its legs (the server
76
93
  // never started; `database is locked`) contributes NOTHING to `legs`, so
77
94
  // deadLegs cannot see it either — which is how run v441plan01 recorded
@@ -83,12 +100,12 @@ async function launchStage1(ctx) {
83
100
  if (got.length > 0 || abort) { return; }
84
101
  if (r.errorDoc && r.errorDoc.code === 'BUDGET_EXCEEDED') { return; }
85
102
  deadWaves.push({
86
- waveId: seated[i].waveId, models: seated[i].models,
103
+ waveId: seated[i].waveId, models: seated[i].models, seats: seated[i].roster,
87
104
  reason: (r.wave && (r.wave.reason || r.wave.error))
88
105
  || (r.errorDoc && r.errorDoc.message) || 'the wave produced no legs',
89
106
  });
90
107
  });
91
- return { aborted, legs, deadWaves };
108
+ return { aborted, legs, deadWaves, waves };
92
109
  }
93
110
 
94
111
  module.exports = { launchStage1 };