amicus 4.6.3 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +52 -4
  7. package/docs/council.md +65 -12
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/testing.md +1 -1
  11. package/docs/troubleshooting.md +33 -5
  12. package/docs/usage.md +64 -14
  13. package/electron/workspace-ui/index.html +3 -0
  14. package/electron/workspace-ui/live-model.js +52 -14
  15. package/electron/workspace-ui/workspace-app.js +14 -3
  16. package/electron/workspace-ui/workspace-lazy.js +233 -0
  17. package/electron/workspace-ui/workspace-matrix.js +12 -1
  18. package/electron/workspace-ui/workspace-panels.js +24 -171
  19. package/electron/workspace-ui/workspace-render.js +6 -2
  20. package/electron/workspace-ui/workspace-seats.js +68 -0
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +8 -4
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/scripts/postinstall.js +6 -3
  29. package/scripts/setup-hooks.js +49 -3
  30. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  31. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  32. package/skills/sidecar/SKILL.md +9 -2
  33. package/src/cli-council-run-bench.js +86 -0
  34. package/src/cli-handlers-council-run.js +65 -81
  35. package/src/cli-handlers-council.js +17 -5
  36. package/src/cli-handlers-fanout.js +179 -0
  37. package/src/cli-handlers-pack.js +24 -10
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/cli-handlers-run.js +19 -161
  40. package/src/cli-template-args.js +48 -0
  41. package/src/cli.js +39 -46
  42. package/src/council/debate.js +89 -10
  43. package/src/council/ledger.js +72 -11
  44. package/src/council/report.js +17 -6
  45. package/src/council/run-assemble.js +15 -3
  46. package/src/council/run-budget.js +2 -2
  47. package/src/council/run-chair.js +61 -5
  48. package/src/council/run-debate.js +51 -67
  49. package/src/council/run-launch.js +20 -2
  50. package/src/council/run-retry.js +17 -2
  51. package/src/council/run-stage1-launch.js +94 -0
  52. package/src/council/run-stage2.js +25 -4
  53. package/src/council/run-stages.js +79 -86
  54. package/src/council/run-state.js +10 -2
  55. package/src/council/run.js +26 -2
  56. package/src/council/tally.js +6 -2
  57. package/src/headless.js +69 -6
  58. package/src/mcp-council-awareness.js +1 -0
  59. package/src/mcp-council-bench.js +4 -0
  60. package/src/mcp-council-run.js +10 -0
  61. package/src/mcp-server.js +114 -54
  62. package/src/mcp-tools.js +12 -5
  63. package/src/pack/pack-cli.js +1 -1
  64. package/src/pack/pack-forward.js +12 -4
  65. package/src/pack/pack-resolve.js +3 -0
  66. package/src/pack/pack-store.js +20 -3
  67. package/src/pack/pack-validate.js +5 -1
  68. package/src/sidecar/budget.js +38 -4
  69. package/src/sidecar/continue.js +8 -23
  70. package/src/sidecar/fanout-budget.js +1 -2
  71. package/src/sidecar/fanout-leg-fallback.js +7 -3
  72. package/src/sidecar/fanout-retry.js +15 -3
  73. package/src/sidecar/fanout-wave-io.js +13 -1
  74. package/src/sidecar/fanout.js +11 -9
  75. package/src/sidecar/list-limit.js +50 -0
  76. package/src/sidecar/list-search.js +69 -0
  77. package/src/sidecar/read.js +90 -5
  78. package/src/sidecar/reopen-spend.js +32 -0
  79. package/src/sidecar/resume.js +1 -1
  80. package/src/sidecar/start-metadata.js +58 -0
  81. package/src/sidecar/start.js +8 -43
  82. package/src/sidecar/workspace-auto-open.js +2 -2
  83. package/src/spend-query.js +2 -1
  84. package/src/template/apply.js +7 -4
  85. package/src/template/render.js +6 -2
  86. package/src/template/store.js +1 -1
  87. package/src/utils/cli-preflight.js +27 -1
  88. package/src/utils/config.js +15 -0
  89. package/src/utils/doctor-engine-check.js +32 -0
  90. package/src/utils/engine-install-scan.js +98 -15
  91. package/src/utils/engine-repair.js +96 -2
  92. package/src/utils/remediation-hints.js +29 -0
  93. package/src/utils/result-schema-rebuild.js +1 -0
  94. package/src/utils/result-schema.js +6 -1
  95. package/src/utils/session-index-tmp-sweep.js +18 -3
  96. package/src/utils/session-index.js +1 -0
  97. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  98. package/src/utils/spend-ledger.js +11 -4
  99. package/src/utils/validators.js +16 -0
@@ -3,11 +3,55 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getConfigDir } = require('../utils/config');
6
- const { DEBATE_ROLES } = require('./debate');
7
6
 
8
- const LEDGER_SCHEMA_VERSION = 1;
7
+ // v4.7 GOA-7 D9: v2 rows may carry `resolvedModel` (the executable id that
8
+ // served, copied from the joined runStats row, emit-only-when-set). Absent
9
+ // resolvedModel ⇒ legacy row, aggregated under its alias (spec R2) — this
10
+ // covers ALL pre-v2 history AND leg-less v2 rows (give-up chair, dead seats,
11
+ // claude, hand-assembled tally input), whose resolution is genuinely
12
+ // unknowable. Legacy-READ only: readers never inspect schemaVersion, rows are
13
+ // never migrated.
14
+ const LEDGER_SCHEMA_VERSION = 2;
9
15
  const LEDGER_FILE = 'council-ledger.jsonl';
10
16
 
17
+ // v4.7 D4/E1/E2/E6 (Task-7, task-6/task-7 adjudications): fail-closed
18
+ // ALLOWLIST of runStats roles the ledger join (below) may consume as a
19
+ // model's ledger identity. 'council' is the legacy default role (pre-#83
20
+ // runs, and the av-receiver golden fixture — errata E2, must stay green).
21
+ // 'redteam' is the second-opinion skill's documented primary-seat role
22
+ // (skills/second-opinion/MANUAL-ORCHESTRATION.md:147; red-team runs record
23
+ // to the ledger per COUNCIL-DESIGN.md:266 — errata E6, task-7 review: without
24
+ // it a red-team row's role/wasChair/conformance never join, silently
25
+ // fabricating conformance:'clean' via the `|| 'clean'` fallback below).
26
+ // 'judge' stays excluded (#83's overwrite-guard: judges ARE bench models, and
27
+ // their Stage-2 cost-attribution row must never win over the seat row). Every
28
+ // OTHER non-primary row-per-launch producer — 'chair-attempt', 'repair',
29
+ // 'superseded', and the debate pair 'rebuttal'/'revote' — shares a model with
30
+ // that model's real bench row and must never join either: skipped by
31
+ // omission, including any future role never added here (fail-closed, not a
32
+ // skip-list that a new producer could silently slip past). This is the E6
33
+ // trade, made explicit: any free-form/custom role label a future producer
34
+ // invents is rejected BY DESIGN until someone deliberately adds it here —
35
+ // the allowlist would rather silently drop a legitimate new role's
36
+ // role/wasChair/conformance (falling back to 'council'/false/'clean') than
37
+ // ever let an unreviewed role win the model-keyed join.
38
+ //
39
+ // Final-review consolidated wave (owner-ruled): the ABSENCE of a role
40
+ // (null/undefined) is a DIFFERENT case from a NAMED-unknown role and joins
41
+ // too — this is the docs/council.md:562-blessed hand-assembled tally-input
42
+ // shape ("the legacy default `council` … pre-#83 rows, or hand-assembled
43
+ // tally input that never set a role"), and mirrors GOA-7's absent-field⇒
44
+ // legacy pattern elsewhere in this codebase: a field that was never set gets
45
+ // treated as the oldest/legacy shape, not silently dropped like an
46
+ // unreviewed value would be. A NAMED custom label (e.g. 'custom-thing') is
47
+ // still rejected exactly as E6 describes — only the missing-field case is
48
+ // legacy; an actively wrong or unreviewed one is not.
49
+ const LEDGER_JOIN_ROLES = new Set(['seat', 'critic', 'chair', 'claude', 'council', 'redteam']);
50
+ function joinsLedger(role) {
51
+ return role === null || role === undefined ||
52
+ LEDGER_JOIN_ROLES.has(role) || (typeof role === 'string' && role.startsWith('lens:'));
53
+ }
54
+
11
55
  function countSeverity(findings) {
12
56
  const c = { blocker: 0, major: 0, minor: 0, nit: 0 };
13
57
  for (const f of findings) { if (c[f.severity] !== undefined) { c[f.severity] += 1; } }
@@ -18,10 +62,10 @@ function countSeverity(findings) {
18
62
  function buildLedgerRows(record) {
19
63
  const { meta, findings, streetCred, runStats, judged } = record;
20
64
  const sc = new Map(streetCred.map(s => [s.model, s]));
21
- // Judge rows (#83, v4.6 Plan 2) are Stage-2 cost attribution, not seats: the
22
- // join below is keyed by MODEL, and judges ARE bench models without this
23
- // exclusion the judge row would silently overwrite the seat row.
24
- const rs = new Map(runStats.filter(r => !DEBATE_ROLES.has(r.role) && r.role !== 'judge')
65
+ // The join below is keyed by MODEL only allowlisted roles (joinsLedger,
66
+ // above) may win it, so a non-primary row-per-launch row can never
67
+ // silently overwrite a model's real bench (seat) row.
68
+ const rs = new Map(runStats.filter(r => joinsLedger(r.role))
25
69
  .map(r => [r.model, r]));
26
70
  return meta.models.map(model => {
27
71
  const raised = findings.filter(f => f.raiser === model);
@@ -39,6 +83,7 @@ function buildLedgerRows(record) {
39
83
  confirmRate: judged && denom ? raised.filter(f => f.tier === 'Confirmed').length / denom : null,
40
84
  factErrorRate: judged && denom ? raised.filter(f => f.tier === 'Disputed').length / denom : null,
41
85
  conformance: r.conformance || 'clean',
86
+ ...(r.resolvedModel ? { resolvedModel: r.resolvedModel } : {}),
42
87
  };
43
88
  });
44
89
  }
@@ -61,25 +106,41 @@ function readRows(dir) {
61
106
 
62
107
  function avg(nums) { return nums.length ? nums.reduce((s, x) => s + x, 0) / nums.length : null; }
63
108
 
64
- /** Aggregate the ledger per model. peersOnly nulls excluded; lowN flags < 3 runs. */
109
+ /**
110
+ * Aggregate the ledger per model. peersOnly nulls excluded; lowN flags < 3 runs.
111
+ * v4.7 GOA-7 D10: groups by `row.resolvedModel || row.model` — v2 rows segment
112
+ * by the executable id that actually served; rows without a resolvedModel
113
+ * (pre-v2 history, leg-less rows, hand-assembled tally input) stay alias-keyed
114
+ * with `legacy: true`. `aliases` lists every row-level `model` (alias) observed
115
+ * for the group, most recently observed FIRST — ledger append order is the only
116
+ * recency signal (`date` is day-granular, free-form on the MCP path), so
117
+ * aliases[0] is the launch-preferred name (pickFallbackChair, D11).
118
+ * Version-blind by design: schemaVersion is never read (legacy-read, R2).
119
+ */
65
120
  function deriveReliability(opts = {}) {
66
121
  const dir = opts.dir || getConfigDir();
67
- const byModel = new Map();
122
+ const byKey = new Map();
68
123
  for (const row of readRows(dir)) {
69
- if (!byModel.has(row.model)) { byModel.set(row.model, []); }
70
- byModel.get(row.model).push(row);
124
+ const key = row.resolvedModel || row.model;
125
+ if (!byKey.has(key)) { byKey.set(key, []); }
126
+ byKey.get(key).push(row);
71
127
  }
72
- return [...byModel.entries()].map(([model, rows]) => {
128
+ return [...byKey.entries()].map(([model, rows]) => {
73
129
  const peers = rows.map(r => r.streetCredPeersOnly).filter(v => typeof v === 'number');
74
130
  const confirms = rows.map(r => r.confirmRate).filter(v => typeof v === 'number');
75
131
  const facts = rows.map(r => r.factErrorRate).filter(v => typeof v === 'number');
76
132
  const conformance = rows.reduce((acc, r) => { acc[r.conformance] = (acc[r.conformance] || 0) + 1; return acc; }, {});
133
+ const lastSeen = new Map();
134
+ rows.forEach((r, i) => { lastSeen.set(r.model, i); });
135
+ const aliases = [...lastSeen.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
77
136
  return {
78
137
  model, runs: rows.length, lowN: rows.length < 3,
79
138
  avgStreetCredPeersOnly: avg(peers),
80
139
  lifetimeConfirmRate: avg(confirms),
81
140
  lifetimeFactErrorRate: avg(facts),
82
141
  conformance,
142
+ aliases,
143
+ ...(rows.every(r => !r.resolvedModel) ? { legacy: true } : {}),
83
144
  };
84
145
  });
85
146
  }
@@ -66,13 +66,24 @@ function toModel(verdict, wave) {
66
66
  .map(f => ({ id: f.id, previousTier: f.debate.previousTier, tier: f.tier })),
67
67
  };
68
68
  const runStats = verdict.runStats || [];
69
- // Cost-row role tag (Plan 2 final review F1): #83 gave judges their own
70
- // runStats row, so a bench model can now appear twice (seat + judge),
71
- // indistinguishable by `model` alone. Tag ONLY judge rows — old verdicts have
72
- // no judge rows at all, so chair/critic/lens/seat rows stay byte-identical to
73
- // their historical rendering.
69
+ // Cost-row role tag (Plan 2 final review F1, extended v4.7 D6): #83 gave
70
+ // judges their own runStats row, so a bench model can now appear twice
71
+ // (seat + judge), indistinguishable by `model` alone. v4.7's row-per-launch
72
+ // producers (chair-attempt/repair/superseded) create the exact same
73
+ // collision for their model. Tag ONLY these four roles — old verdicts have
74
+ // none of them, so chair/critic/lens/seat rows stay byte-identical to their
75
+ // historical rendering (report.test.js:189-199 pins the judge case exactly).
76
+ // Object.create(null): a plain `{...}` literal inherits Object.prototype, so a role
77
+ // literally named 'constructor'/'toString'/etc would resolve to an inherited (truthy)
78
+ // function via bracket lookup instead of `undefined` — silently corrupting that row's
79
+ // rendered model label. A null-prototype object has no inherited keys to collide with.
80
+ const ROLE_SUFFIX = Object.create(null);
81
+ ROLE_SUFFIX.judge = 'judge';
82
+ ROLE_SUFFIX['chair-attempt'] = 'chair-attempt';
83
+ ROLE_SUFFIX.repair = 'repair';
84
+ ROLE_SUFFIX.superseded = 'superseded';
74
85
  const costRows = runStats.map(r => ({
75
- model: r.role === 'judge' ? `${r.model} (judge)` : r.model,
86
+ model: ROLE_SUFFIX[r.role] ? `${r.model} (${ROLE_SUFFIX[r.role]})` : r.model,
76
87
  status: r.status, durationMs: r.durationMs,
77
88
  cost: r.usage && r.usage.cost ? r.usage.cost : null,
78
89
  }));
@@ -40,6 +40,9 @@ function worseConformance(a, b) {
40
40
  * leg doc yields durationMs/usage null (never invent a value). `model` (the
41
41
  * council alias) overrides leg.model (the resolved executable id) so ledger
42
42
  * rows join meta.models by exact string (ledger.js:20-24).
43
+ * `resolvedModel` (v4.7 GOA-7) preserves leg.model — the executable id that
44
+ * actually served, post-fallback-substitution — emit-only-when-set and never
45
+ * sourced from modelInput (an alias must never masquerade as a resolved id).
43
46
  *
44
47
  * ⚠️ LC-11 / review F1: `findingsUnverified` and `repairRefused` are the same
45
48
  * class of fact as `conformance` and ride the same row. They are the two halves
@@ -60,6 +63,8 @@ function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsU
60
63
  conformance: conformance || 'clean',
61
64
  ...(findingsUnverified ? { findingsUnverified: true } : {}),
62
65
  ...(repairRefused ? { repairRefused } : {}),
66
+ ...(leg && leg.waveId ? { waveId: leg.waveId } : {}),
67
+ ...(leg && leg.model ? { resolvedModel: leg.model } : {}),
63
68
  status: leg ? leg.status : 'error',
64
69
  durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
65
70
  usage: (leg && leg.usage) || null,
@@ -131,14 +136,17 @@ function claudeRunStatsRow() {
131
136
  * @param {{runId: string, date: string, bench: string[], chair: string,
132
137
  * reviews: Array<{model, role, conformance, leg, globalFindings}>,
133
138
  * judgeResults: Array<{judge, ok, order, adjudications}>,
134
- * chairStats: object|null, claudeReview?: object|null}} args
139
+ * chairStats: object|null, claudeReview?: object|null, extraRows?: Array<object>}} args
135
140
  * `claudeReview` (v4.1 §4.4) amends the v4.0 meta pin: present ⇒ claudeInCouncil
136
141
  * true, 'claude' joins meta.models (the street-cred universe), its findings join
137
142
  * the pool and it gets the synthesized null-usage runStats row. Absent ⇒ v4.0
138
- * output byte-for-byte.
143
+ * output byte-for-byte. `extraRows` (v4.7 D2/E4) are pre-built runStats rows
144
+ * (repair/superseded/dead-seat-error, from runStage1 today) appended right
145
+ * after the primary review rows, before judge/chair accounting — absent or
146
+ * empty ⇒ byte-for-byte unchanged, so the pre-v4.7 length-7 pins stay green.
139
147
  */
140
148
  function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, chairStats,
141
- claudeReview }) {
149
+ claudeReview, extraRows }) {
142
150
  const meta = {
143
151
  runId, date, runType: 'headless',
144
152
  models: bench.slice(), // bench seats exactly: critic included, chair excluded
@@ -154,6 +162,10 @@ function buildTallyInput({ runId, date, bench, chair, reviews, judgeResults, cha
154
162
  leg: r.leg, model: r.model, role: r.role, wasChair: false, conformance: r.conformance,
155
163
  findingsUnverified: r.findingsUnverified, repairRefused: r.repairRefused,
156
164
  }));
165
+ // v4.7 D2/E4: pre-built rows (repair/superseded/dead-seat-error) ride right
166
+ // after the primary review rows — same "primary-adjacent" shape, just not
167
+ // sourced from a surviving review. Absent/empty ⇒ no-op (pre-v4.7 byte parity).
168
+ runStats.push(...(extraRows || []));
157
169
  if (claudeReview) {
158
170
  meta.models.push(CLAUDE_SEAT); // last, mirroring its review-N+1 label
159
171
  meta.claudeInCouncil = true;
@@ -153,9 +153,9 @@ function createBudget({ allLegs, maxCost, runDir, degrade, write }) {
153
153
  channel: 'budget-refusal',
154
154
  what: `wave ${rec.waveId} (${rec.models.join(', ') || 'no models'}) — those seats DID NOT `
155
155
  + 'LAUNCH and are missing from this council',
156
- why: `the $${maxCost} --max-cost ceiling refused it${message ? `: ${message}` : ''}`,
156
+ why: `the $${maxCost} cost ceiling for this run refused it${message ? `: ${message}` : ''}`,
157
157
  effect: 'The run continues with the bench that did launch and will exit degraded (2)',
158
- remedy: 'Raise --max-cost, or pass --no-cost-gate, to seat them',
158
+ remedy: "Raise this run's cost ceiling, or turn the cost gate off, to seat them",
159
159
  });
160
160
  if (runDir) {
161
161
  // Never let bookkeeping sink a run that is otherwise fine.
@@ -20,6 +20,7 @@ const { parseChairVerdict } = require('./parse-stage2');
20
20
  const runState = require('./run-state');
21
21
  const { isAbortExit } = require('./run-stages');
22
22
  const { emitStageStarted, emitStageTerminal } = require('../observe/events');
23
+ const { buildRunStatsEntry } = require('./run-assemble');
23
24
 
24
25
  /**
25
26
  * Chair fallback promotion (spec §4): the highest peers-only street-cred
@@ -31,15 +32,33 @@ const { emitStageStarted, emitStageTerminal } = require('../observe/events');
31
32
  * a --claude-review run puts a real 'claude' row in the ledger, so without this
32
33
  * filter a LATER run could promote it and walk straight past the pre-flight
33
34
  * --chair claude guard — with no Claude leg to launch.
35
+ *
36
+ * v4.7 GOA-7 D11: exclusions test the group key AND aliases[]; the promoted
37
+ * name is aliases[0] (most-recent alias) so the launch string stays routable
38
+ * through the same alias policy both call sites (run.js mid-walk, run-server.js
39
+ * pre-seed) already resolve.
34
40
  * @returns {string|null}
35
41
  */
36
42
  function pickFallbackChair(statsRows, bench, failedChair) {
37
43
  const benchSet = new Set(bench);
44
+ // v4.7 GOA-7 D11: an aggregate's identity is its key PLUS every alias it was
45
+ // observed under — post-D10 keys may be executable ids while bench/o.chair
46
+ // stay alias-space, so every exclusion tests the whole name set (a bench
47
+ // seat's resolved-keyed group must never be promoted as its own chair).
48
+ // The LAUNCHED name is aliases[0] (most-recent alias): alias-space names
49
+ // re-enter the router's alias bridge and current key/gateway policy; a raw
50
+ // executable id would dodge them (divergent-vendor forms, openrouter-
51
+ // literals under --gateway direct, dropped aliases). aliases[] is non-empty
52
+ // for every ledger-derived group; the bare-model fallback covers pre-D10
53
+ // aggregate shapes only.
54
+ const names = (r) => [r.model, ...(Array.isArray(r.aliases) ? r.aliases : [])];
55
+ const excluded = (r) => names(r).some(n => n === 'claude' || benchSet.has(n) || n === failedChair);
38
56
  const candidates = (statsRows || [])
39
- .filter(r => r.model !== 'claude' && !benchSet.has(r.model) && r.model !== failedChair
40
- && typeof r.avgStreetCredPeersOnly === 'number')
57
+ .filter(r => !excluded(r) && typeof r.avgStreetCredPeersOnly === 'number')
41
58
  .sort((a, b) => a.avgStreetCredPeersOnly - b.avgStreetCredPeersOnly);
42
- return candidates.length ? candidates[0].model : null;
59
+ if (!candidates.length) { return null; }
60
+ const top = candidates[0];
61
+ return (Array.isArray(top.aliases) && top.aliases.length) ? top.aliases[0] : top.model;
43
62
  }
44
63
 
45
64
  /**
@@ -98,6 +117,7 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
98
117
  // v4.3 Task 3 (spec §7.2 named defect): without this, chair spend is
99
118
  // ledgered with councilRunId:null and is unattributable.
100
119
  councilRunId: o.runId, councilName: o.councilName,
120
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
101
121
  });
102
122
  addWave(solo.wave);
103
123
  const ok = solo.leg && solo.leg.status === 'complete'
@@ -117,12 +137,24 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
117
137
  // cost-skipped chair (the `if` branch) simply never calls recordAttempt —
118
138
  // chairAttempts is never checkpointed and the key stays absent on run.json.
119
139
  const chairAttempts = [];
140
+ // v4.7 D2 (spec "the count is the count"): a non-primary row per launch so
141
+ // spend is fully attributable — one 'chair-attempt' row per FAILED attempt
142
+ // that produced a rawLeg (null rawLeg = no wave = no money = no row), plus
143
+ // one 'repair' row for a launched ch4 (pushed below, after the ch4 block).
144
+ // The eventual SUCCESSFUL attempt's leg is never pushed here — it becomes
145
+ // the primary 'chair' row (wasChair:true) via run.js's own chairStats.
146
+ const chairRows = [];
120
147
  const recordAttempt = (attempt, waveId, model) => {
121
148
  const cls = classifyChairAttempt(attempt.rawLeg, attempt.errorDoc);
122
149
  chairAttempts.push({ waveId, model, outcome: cls.outcome, reason: cls.reason });
123
150
  // Checkpointed HERE, before the caller's own isAbortExit bail — a mid-walk
124
151
  // kill must not lose the attempts already resolved (spec §8 kill-mid-walk).
125
152
  runState.checkpoint(o.runDir, { chairAttempts });
153
+ if (!attempt.leg && attempt.rawLeg) {
154
+ chairRows.push(buildRunStatsEntry({
155
+ leg: attempt.rawLeg, model, role: 'chair-attempt', wasChair: false,
156
+ }));
157
+ }
126
158
  };
127
159
  if (overBudget()) {
128
160
  // Ceiling hit after the tally is computable: skip the chair, write the
@@ -183,19 +215,37 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
183
215
  // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
184
216
  let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
185
217
  if (chairText && !overallVerdict && !overBudget()) {
186
- runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
218
+ const waveId4 = `${o.runId}-ch4`;
219
+ runState.appendStageWave(o.runDir, 'chair', waveId4);
187
220
  const repair = await launchers.launchSolo({
188
221
  // ⚠️ LC-12: the synthesis rides along. The chair leg SUCCEEDED — only the
189
222
  // VERDICT line is missing — so a fresh repair session that cannot see the
190
223
  // synthesis is picking a verdict on an artifact it has never read.
191
224
  model: actualChair, prompt: stage2.buildChairRepairPrompt({ synthesis: chairText }),
192
- project: o.runDir, waveId: `${o.runId}-ch4`,
225
+ project: o.runDir, waveId: waveId4,
193
226
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
194
227
  noCostGate: o.noCostGate,
195
228
  councilRunId: o.runId, councilName: o.councilName,
229
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
196
230
  });
197
231
  addWave(repair.wave);
198
232
  if (isAbortExit(repair.exitCode) || isSignalled()) { return bail(repair.exitCode || isSignalled()); }
233
+ // repair.leg is the raw leg document — launchSolo DOES null it, but only
234
+ // on a wave-less failure (a pre-flight refusal with no wave launched at
235
+ // all: run-launch.js's launchSolo derives `leg` from `wave.legs[0]`, so
236
+ // no wave means no leg, no waveId, no money spent — the errata E3 "no
237
+ // leg = no wave = no money = no row" case). A wave that DID launch
238
+ // always yields a leg document, whatever its status. The `if
239
+ // (repair.leg)` guard below is therefore load-bearing on that exact
240
+ // distinction: a launched ch4 (a leg document exists, whatever its
241
+ // status) gets its own row so the repair's spend is attributed even when
242
+ // it never supplies a VERDICT; a ch4 that never even launched gets no
243
+ // row at all, because there is nothing billed to attribute.
244
+ if (repair.leg) {
245
+ chairRows.push(buildRunStatsEntry({
246
+ leg: repair.leg, model: actualChair, role: 'repair', wasChair: false,
247
+ }));
248
+ }
199
249
  overallVerdict = parseChairVerdict((repair.leg && repair.leg.summary) || '');
200
250
  chairConformance = overallVerdict ? 'repaired' : 'unstructured';
201
251
  }
@@ -216,6 +266,12 @@ async function runChair(ctx, { packet, degrade, statsFn, isSignalled }) {
216
266
 
217
267
  return {
218
268
  aborted: null, chairLeg, actualChair, chairText, chairConformance, overallVerdict,
269
+ // Additive (v4.7 D2): chairRows holds the non-primary rows (attempts +
270
+ // repair); chairAttempts is handed back too so run.js can key the
271
+ // give-up row on "the walk actually happened" without re-reading disk —
272
+ // NOT on chairRows, since attempts that die pre-wave record an outcome
273
+ // but produce no row (errata E3: no wave = no money = no row).
274
+ chairRows, chairAttempts,
219
275
  };
220
276
  }
221
277
 
@@ -13,62 +13,38 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const dbrief = require('./briefings-debate');
15
15
  const { parseDebateDefense, parseRevote } = require('./parse-stage2');
16
- const { applyDebate, debateRunStatsRows, PAST_TENSE } = require('./debate');
16
+ const { applyDebate, debateRunStatsRows, PAST_TENSE,
17
+ allNoResponse, nothingToDebate, disputingJudges, debateTargets, bundleFor } = require('./debate');
17
18
  const { materializeDebate } = require('./run-launch');
18
19
  const { tally } = require('./tally');
19
20
  const { isAbortExit } = require('./run-stages');
20
21
  const runState = require('./run-state');
21
22
  const { emitStageStarted } = require('../observe/events');
22
23
 
23
- /** Spec §5.7 fallback: a dead/unparseable defense means every bundled id's original stands. */
24
- function allNoResponse(ids) {
25
- const byId = {};
26
- for (const id of ids) { byId[id] = { action: 'no-response' }; }
27
- return byId;
28
- }
29
-
30
- /** True when there is nothing to challenge (spec §5.1). */
31
- function nothingToDebate(provisionalRecord) {
32
- if (!provisionalRecord || provisionalRecord.judged === false) { return true; }
33
- const n = provisionalRecord.findings.filter(f => f.tier === 'Contested' || f.tier === 'Disputed').length;
34
- return n === 0;
35
- }
36
-
37
- /** Judges whose provisional adjudications dispute at least one bundled id. */
38
- function disputingJudges(provisionalRecord, bundledIds) {
39
- const ids = new Set(bundledIds);
40
- const judges = new Set();
41
- for (const f of provisionalRecord.findings) {
42
- if (!ids.has(f.id)) { continue; }
43
- for (const adj of f.adjudications || []) {
44
- if (adj.verdict === 'dispute') { judges.add(adj.judge); }
45
- }
46
- }
47
- return [...judges];
48
- }
49
-
50
- /** Group Contested+Disputed findings by raiser (defense targets). */
51
- function debateTargets(provisionalRecord, tallyInput) {
52
- const claimById = new Map(tallyInput.findings.map(f => [f.id, f]));
53
- const byRaiser = {};
54
- const previousTier = {};
55
- for (const f of provisionalRecord.findings) {
56
- if (f.tier !== 'Contested' && f.tier !== 'Disputed') { continue; }
57
- previousTier[f.id] = f.tier;
58
- const src = claimById.get(f.id) || {};
59
- const peerVerdicts = (f.adjudications || []).filter(a => a.judge !== f.raiser).map(a => a.verdict);
60
- (byRaiser[f.raiser] = byRaiser[f.raiser] || []).push({ id: f.id, claim: src.claim,
61
- severity: f.severity, location: src.location, peerVerdicts, disputeReasons: [] });
62
- }
63
- return { byRaiser, previousTier };
64
- }
65
-
66
24
  /** Common launch options for every debate leg (judge-isolated `_scratch` cwd). */
67
25
  function legOpts(ctx, waveId) {
68
26
  return { project: ctx.scratchDir, waveId, timeout: ctx.o.timeout, gateway: ctx.o.gateway,
69
27
  noValidateModel: ctx.o.noValidateModel, noCostGate: ctx.o.noCostGate,
70
28
  // v4.3 Task 3 (spec §7.2): attribution ids for every defense/re-vote leg.
71
- councilRunId: ctx.o.runId, councilName: ctx.o.councilName };
29
+ councilRunId: ctx.o.runId, councilName: ctx.o.councilName,
30
+ tag: ctx.o.tag }; // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
31
+ }
32
+
33
+ /**
34
+ * v4.7 D2/E4: normalize a raw (possibly leg-absent) leg into the shape
35
+ * debateRunStatsRows' superseded/repair lists expect. Same never-invent-a-waveId
36
+ * discipline as buildRunStatsEntry (run-assemble.js) — only spread `waveId` when
37
+ * the leg genuinely carries one — but keyed on an explicit `model` (the raiser or
38
+ * judge identity), since a leg-absent attempt has no `.model` of its own to read.
39
+ * Threads resolvedModel (the raw leg's .model, the executable id) emit-only-when-set — v4.7 GOA-7 D8.
40
+ */
41
+ function legRow(model, leg, conformance) {
42
+ return leg
43
+ ? { model, status: leg.status, durationMs: typeof leg.durationMs === 'number' ? leg.durationMs : null,
44
+ usage: leg.usage || null, conformance, summary: leg.summary || '',
45
+ ...(leg.waveId ? { waveId: leg.waveId } : {}),
46
+ ...(leg.model ? { resolvedModel: leg.model } : {}) }
47
+ : { model, status: 'error', durationMs: null, usage: null, conformance, summary: '' };
72
48
  }
73
49
 
74
50
  async function runDefenseSolo(ctx, raiser, findings, idx) {
@@ -90,6 +66,12 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
90
66
  let parsed = leg ? parseDebateDefense(leg.summary, expectedIds)
91
67
  : { ok: false, byId: allNoResponse(expectedIds), errors: [{ code: 'DEAD_LEG', detail: 'no summary' }] };
92
68
  let conformance = leg ? 'clean' : 'unstructured';
69
+ // v4.7 D2/E4: the repair's loser leg — the ORIGINAL when the repair produced a
70
+ // usable (complete) leg (today's leg-swap below is unchanged), or the failed
71
+ // repair attempt itself when it did not — retained so runDebate can turn it
72
+ // into an extra debate-defense runStats row. Both stay null when no repair is
73
+ // attempted at all (today's single-row shape, byte-identical).
74
+ let supersededLeg = null, repairLeg = null;
93
75
  if (leg && !parsed.ok) {
94
76
  const repairId = `${waveId}r`;
95
77
  runState.appendStageWave(ctx.o.runDir, 'debate-defense', repairId);
@@ -103,13 +85,17 @@ async function runDefenseSolo(ctx, raiser, findings, idx) {
103
85
  const leg2 = res2.leg && res2.leg.status === 'complete' ? res2.leg : null;
104
86
  parsed = leg2 ? parseDebateDefense(leg2.summary, expectedIds) : parsed;
105
87
  conformance = parsed.ok ? 'repaired' : 'unstructured';
106
- if (leg2) { leg = leg2; }
88
+ if (leg2) { supersededLeg = legRow(raiser, leg, 'unstructured'); leg = leg2; }
89
+ else { repairLeg = legRow(raiser, res2.leg, 'unstructured'); }
107
90
  }
108
91
  // A dead leg (no complete summary) OR an 'unstructured' conformance after the one
109
92
  // repair is a debate degradation (spec §5.7) — surfaced via the returned leg.
110
93
  const stub = { model: raiser, status: 'error', durationMs: null, usage: null, conformance: 'unstructured', summary: '' };
111
94
  return { raiser, byId: parsed.byId,
112
- leg: leg ? { model: raiser, status: leg.status, durationMs: leg.durationMs, usage: leg.usage, conformance, summary: leg.summary } : stub };
95
+ leg: leg ? { model: raiser, status: leg.status, durationMs: leg.durationMs, usage: leg.usage,
96
+ conformance, summary: leg.summary, waveId: leg.waveId,
97
+ ...(leg.model ? { resolvedModel: leg.model } : {}) } : stub,
98
+ supersededLeg, repairLeg };
113
99
  }
114
100
 
115
101
  async function runRevoteWave(ctx, judges, bundleFindings) {
@@ -131,6 +117,9 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
131
117
  ctx.addWave(res.wave);
132
118
  if (isAbortExit(res.exitCode)) { return { aborted: res.exitCode }; }
133
119
  const byJudge = {}, legs = [];
120
+ // v4.7 D2/E4: mirrors runDefenseSolo's supersededLeg/repairLeg — one list each,
121
+ // accumulated across every judge in this wave (most judges contribute neither).
122
+ const supersededLegs = [], repairLegs = [];
134
123
  for (const leg of ((res.wave && res.wave.legs) || [])) {
135
124
  // The council ALIAS, not the resolved executable id — runStats rows join
136
125
  // meta.models by exact string (run-assemble.js's buildRunStatsEntry).
@@ -154,27 +143,15 @@ async function runRevoteWave(ctx, judges, bundleFindings) {
154
143
  conformance = parsed.ok ? 'repaired' : 'unstructured';
155
144
  // Symmetric with runDefenseSolo's `if (leg2) { leg = leg2; }` — otherwise
156
145
  // revote-<model>.md and the runStats row keep the PRE-repair output.
157
- if (leg2) { outLeg = leg2; }
146
+ if (leg2) { supersededLegs.push(legRow(judge, leg, 'unstructured')); outLeg = leg2; }
147
+ else { repairLegs.push(legRow(judge, r2.leg, 'unstructured')); }
158
148
  }
159
149
  byJudge[judge] = parsed.byId;
160
- legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage, conformance, summary: outLeg.summary || '' });
161
- }
162
- return { byJudge, legs };
163
- }
164
-
165
- /** The re-vote bundle: defended-or-amended findings ONLY (spec §5.1 — withdrawn never appear). */
166
- function bundleFor(defenseResults, tallyInput) {
167
- const out = [];
168
- for (const dr of defenseResults) {
169
- for (const [id, resp] of Object.entries(dr.byId)) {
170
- if (resp.action !== 'defend' && resp.action !== 'amend') { continue; }
171
- const src = tallyInput.findings.find(f => f.id === id) || {};
172
- out.push({ id, severity: src.severity, amended: resp.action === 'amend',
173
- claim: resp.action === 'amend' ? resp.claim : src.claim,
174
- argument: resp.argument || 'defended without extra argument' });
175
- }
150
+ legs.push({ model: judge, status: outLeg.status, durationMs: outLeg.durationMs, usage: outLeg.usage,
151
+ conformance, summary: outLeg.summary || '', waveId: outLeg.waveId,
152
+ ...(outLeg.model ? { resolvedModel: outLeg.model } : {}) });
176
153
  }
177
- return out;
154
+ return { byJudge, legs, supersededLegs, repairLegs };
178
155
  }
179
156
 
180
157
  /**
@@ -221,7 +198,7 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
221
198
  const stampedInput = { ...tallyInput, findings: tallyInput.findings.map(f => ({ ...f, previousTier: previousTier[f.id] })) };
222
199
 
223
200
  // ---- Re-vote mini-wave (disputing judges only) ----
224
- let revoteByJudge = {}, revoteLegs = [];
201
+ let revoteByJudge = {}, revoteLegs = [], revoteSuperseded = [], revoteRepairs = [];
225
202
  const defendedOrAmended = bundleFor(defenseResults, tallyInput);
226
203
  const judges = disputingJudges(provisionalRecord, defendedOrAmended.map(f => f.id));
227
204
  // A re-vote is warranted only when something was defended/amended AND ≥1 judge disputed it.
@@ -238,6 +215,8 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
238
215
  if (rv.aborted) { return { aborted: rv.aborted, contested, disputed }; }
239
216
  revoteByJudge = rv.byJudge;
240
217
  revoteLegs = rv.legs;
218
+ revoteSuperseded = rv.supersededLegs;
219
+ revoteRepairs = rv.repairLegs;
241
220
  // revote-<model>.md per surviving judge leg, mirroring rebuttal-<model>.md
242
221
  // (spec §5.1 'raw outputs revote-<model>.md').
243
222
  materializeDebate(ctx.o.runDir, revoteLegs, 'revote');
@@ -247,7 +226,12 @@ async function runDebate(ctx, { provisionalRecord, tallyInput }) {
247
226
  const { input: debatedInput, debateFindings } = applyDebate({
248
227
  tallyInput: stampedInput, provisionalRecord, defenseByRaiser, revoteByJudge });
249
228
  debatedInput.runStats = [...(debatedInput.runStats || []),
250
- ...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs })];
229
+ ...debateRunStatsRows({ defenseLegs: defenseResults.map(d => d.leg), revoteLegs,
230
+ // v4.7 D2/E4: the retained loser legs from every raiser's defense repair
231
+ // plus every judge's re-vote repair — same append, no new channel into
232
+ // buildTallyInput.
233
+ supersededLegs: [...defenseResults.map(d => d.supersededLeg).filter(Boolean), ...revoteSuperseded],
234
+ repairLegs: [...defenseResults.map(d => d.repairLeg).filter(Boolean), ...revoteRepairs] })];
251
235
 
252
236
  // verdictChanges: findings whose tier moved from provisional to debated.
253
237
  const provTierById = new Map(provisionalRecord.findings.map(f => [f.id, f.tier]));
@@ -63,12 +63,18 @@ function createLaunchers(deps = {}) {
63
63
  /**
64
64
  * @param {{models: string[], prompt: string, project: string, waveId: string,
65
65
  * timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
66
- * councilRunId?: string, councilName?: string, fallback?: object, catalog?: Array}} opts
66
+ * councilRunId?: string, councilName?: string, tag?: string, fallback?: object,
67
+ * catalog?: Array, noOutputBackstopMs?: number}} opts
67
68
  * councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
68
69
  * ids forwarded verbatim into the runFanout call so it can stamp them onto
69
- * every leg. fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
70
+ * every leg. tag (v4.7 F8 D16) rides the same forward — every call site
71
+ * below that sets councilRunId/councilName sets `tag: o.tag` alongside it.
72
+ * fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
70
73
  * additive/opt-in — omitted by callers that must never substitute (the
71
74
  * chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
75
+ * noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
76
+ * Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
77
+ * to escalate the window on a Stage-1 retry.
72
78
  * @returns {Promise<{wave: object|null, exitCode: number}>}
73
79
  */
74
80
  async function launchWave(opts) {
@@ -111,6 +117,18 @@ function createLaunchers(deps = {}) {
111
117
  noValidateModel: opts.noValidateModel,
112
118
  councilRunId: opts.councilRunId,
113
119
  councilName: opts.councilName,
120
+ // v4.7 F8 D16: rides the SAME forward as councilRunId/councilName above —
121
+ // undefined when no --tag, so stampLegAttribution's `if (options.tag)`
122
+ // guard (fanout-wave-io.js) simply no-ops, byte-identical to today.
123
+ tag: opts.tag,
124
+ // Task 5 (#129): spread-guarded on Number.isFinite, NOT on truthiness —
125
+ // an explicit 0 is this knob's documented disable hatch
126
+ // (no-output-backstop.js:13-15) and a truthiness guard would silently
127
+ // drop it. Guarding at all — rather than a plain
128
+ // `noOutputBackstopMs: opts.noOutputBackstopMs` — keeps the transport
129
+ // call key-identical for run-stage1-launch / run-stage2 / run-chair /
130
+ // run-debate, none of which set it.
131
+ ...(Number.isFinite(opts.noOutputBackstopMs) ? { noOutputBackstopMs: opts.noOutputBackstopMs } : {}),
114
132
  // v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
115
133
  // substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
116
134
  // fallback path only activates when `fallback.enabled` is true.
@@ -17,6 +17,7 @@
17
17
  const briefings = require('./briefings');
18
18
  const { materializeReviews, isAbortExit } = require('./run-launch');
19
19
  const runState = require('./run-state');
20
+ const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
20
21
  const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
21
22
  = require('./run-retry-notes');
22
23
 
@@ -141,7 +142,18 @@ function briefingFor(o, unit) {
141
142
  async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts = { reviewed: 0, total: 0 } } = {}) {
142
143
  const { o, launchers } = ctx;
143
144
  const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
144
- stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [] };
145
+ stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
146
+ stillDeadRetryLegs: [] };
147
+ // Task 5 (#129): SL-2 retries the SAME model under the SAME conditions, so a
148
+ // latency failure is structurally unhealable. Double the window, clamped to
149
+ // the leg timeout so the failure CLASS stays NO_OUTPUT_BACKSTOP rather than
150
+ // silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
151
+ // the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
152
+ const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
153
+ const escalatedBackstopMs = Math.min(
154
+ 2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs()),
155
+ legTimeoutMs,
156
+ );
145
157
 
146
158
  for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
147
159
  // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
@@ -168,8 +180,10 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
168
180
  const common = { project: o.runDir, timeout: o.timeout, gateway: o.gateway,
169
181
  noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
170
182
  councilRunId: o.runId, councilName: o.councilName,
183
+ tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
171
184
  fallback: o.fallback, catalog: o.catalog,
172
- waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit) };
185
+ waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit),
186
+ noOutputBackstopMs: escalatedBackstopMs };
173
187
  // Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
174
188
  // even down to its last surviving seat — critic/lens are always solos).
175
189
  // A model-count proxy (`models.length === 1`) is wrong for a bench unit
@@ -234,6 +248,7 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
234
248
  data: { seat, retryWaveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, firstFailure: ff } });
235
249
  } else {
236
250
  out.stillDeadNotes.push(retryLegStillDeadNote(seat, ff, leg, unit, counts));
251
+ out.stillDeadRetryLegs.push(leg);
237
252
  if (ff && ff.class === 'wave') {
238
253
  if (!lostWaveSeats.has(ff.waveId)) { lostWaveSeats.set(ff.waveId, []); }
239
254
  lostWaveSeats.get(ff.waveId).push(seat);