amicus 4.8.1 → 4.9.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 +249 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +36 -15
  10. package/electron/offer-session.js +51 -0
  11. package/electron/setup-ui.js +18 -8
  12. package/electron/workspace-ui/live-dead-seats.js +163 -91
  13. package/electron/workspace-ui/workspace-banners.js +30 -7
  14. package/electron/workspace-ui/workspace-matrix.js +23 -3
  15. package/electron/workspace-ui/workspace-seats.js +95 -79
  16. package/package.json +1 -1
  17. package/schemas/council-run.schema.json +2 -2
  18. package/schemas/council-tally.schema.json +17 -1
  19. package/schemas/council-verdict.schema.json +12 -4
  20. package/schemas/run.schema.json +6 -1
  21. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  22. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  23. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  24. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  25. package/skills/second-opinion/SKILL.md +151 -36
  26. package/src/cli-council-run-bench.js +98 -6
  27. package/src/cli-handlers-council-run.js +18 -6
  28. package/src/cli-handlers-council.js +57 -7
  29. package/src/cli-handlers-doctor.js +1 -1
  30. package/src/cli.js +3 -1
  31. package/src/council/anonymize.js +2 -1
  32. package/src/council/briefings-chair-task.js +161 -0
  33. package/src/council/briefings-chair.js +33 -8
  34. package/src/council/briefings-debate.js +79 -13
  35. package/src/council/briefings-stage2-task.js +236 -0
  36. package/src/council/briefings-stage2.js +103 -26
  37. package/src/council/briefings-task.js +167 -0
  38. package/src/council/briefings.js +41 -4
  39. package/src/council/chair-fallback.js +95 -0
  40. package/src/council/debate.js +38 -21
  41. package/src/council/findings.js +3 -2
  42. package/src/council/ledger.js +2 -2
  43. package/src/council/parse-stage2.js +63 -15
  44. package/src/council/report-cost.js +61 -0
  45. package/src/council/report-html.js +26 -4
  46. package/src/council/report-md.js +30 -2
  47. package/src/council/report.js +40 -37
  48. package/src/council/run-assemble.js +21 -6
  49. package/src/council/run-chair.js +44 -95
  50. package/src/council/run-debate-revote.js +81 -49
  51. package/src/council/run-debate.js +51 -34
  52. package/src/council/run-finish.js +5 -3
  53. package/src/council/run-retry-keys.js +4 -4
  54. package/src/council/run-retry-launch.js +4 -4
  55. package/src/council/run-retry-notes.js +72 -15
  56. package/src/council/run-stage1-launch.js +4 -4
  57. package/src/council/run-stage1-rows.js +9 -6
  58. package/src/council/run-stage2.js +81 -47
  59. package/src/council/run-stages.js +9 -21
  60. package/src/council/run-stats-entry.js +46 -1
  61. package/src/council/run.js +28 -13
  62. package/src/council/seats.js +2 -2
  63. package/src/council/stage1-bind.js +3 -2
  64. package/src/council/verdict-seat-loss.js +124 -0
  65. package/src/council/verdict.js +108 -99
  66. package/src/headless.js +256 -49
  67. package/src/mcp-council-bench.js +64 -3
  68. package/src/mcp-council-run.js +10 -3
  69. package/src/mcp-server.js +52 -12
  70. package/src/mcp-tools.js +41 -5
  71. package/src/observe/council-legs.js +2 -2
  72. package/src/opencode-client.js +19 -1
  73. package/src/pack/pack-forward.js +15 -12
  74. package/src/pack/pack-resolve.js +1 -1
  75. package/src/prompt-builder.js +17 -1
  76. package/src/sidecar/fanout-leg.js +26 -0
  77. package/src/sidecar/fanout.js +1 -1
  78. package/src/sidecar/list-council.js +178 -0
  79. package/src/sidecar/list-limit.js +3 -1
  80. package/src/sidecar/list-search.js +2 -1
  81. package/src/sidecar/models.js +8 -1
  82. package/src/sidecar/read.js +34 -10
  83. package/src/template/render.js +16 -7
  84. package/src/utils/alias-shadow-writer.js +220 -0
  85. package/src/utils/alias-shadow.js +294 -0
  86. package/src/utils/config.js +1 -1
  87. package/src/utils/curated-models.js +11 -3
  88. package/src/utils/degrade.js +12 -5
  89. package/src/utils/doctor-alias-check.js +2 -5
  90. package/src/utils/engine-log-parse.js +289 -0
  91. package/src/utils/engine-log-tail.js +114 -0
  92. package/src/utils/engine-log.js +250 -0
  93. package/src/utils/engine-skew-records.js +146 -0
  94. package/src/utils/engine-skew.js +300 -0
  95. package/src/utils/gateway-router.js +10 -2
  96. package/src/utils/model-catalog.js +1 -1
  97. package/src/utils/result-schema.js +10 -0
  98. package/src/utils/text-sanitize.js +81 -0
  99. package/src/utils/ttft.js +57 -0
  100. package/src/utils/untrusted-fence.js +111 -1
  101. package/src/workspace/fold-format.js +28 -7
  102. package/src/workspace/live-normalize.js +2 -1
  103. package/src/workspace/matrix-model.js +6 -2
  104. package/src/workspace/run-detail.js +33 -7
  105. package/src/workspace/seat-space.js +10 -6
@@ -36,23 +36,31 @@ function waveStillDeadNote(w, unit) {
36
36
  why: `${w.reason || 'no reason recorded'}; the once-only retry wave also produced no legs`,
37
37
  effect: 'Those seats are NOT in this council. The run continues with the bench that did '
38
38
  + 'launch and will exit degraded (2)',
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-waveso nothing consumes `seat-unbound` yet.
46
- // Cited by SYMBOL, not line: these three references rotted twice during v4.8 PR5c alone.
39
+ // `seat`/`seatId` ride ONLY on the partial shape: adding either unconditionally breaks
40
+ // degrade-channels.test.js's exact toEqual on a real dead wave. `seat` stays the
41
+ // ALIAS because verdict-seat-loss.js :: deriveSeatLoss compares data.seat against o.critic,
42
+ // an alias, and because it is the same key the dead-leg shape uses — one vocabulary
43
+ // for one field. ⚠️ It IS read by the Workspace as of v4.9 W9: all three consumers
44
+ // (live-dead-seats.js :: deadSeats, workspace-seats.js :: retriedSeats,
45
+ // verdict-seat-loss.js :: deriveSeatLoss) now admit `seat-unbound`GATED on the retry-family
46
+ // fields below, because orphan-leg and re-vote notes share this channel and are not
47
+ // seat losses. Cited by SYMBOL, not line: these three references rotted twice during
48
+ // v4.8 PR5c alone.
47
49
  data: { waveId: w.waveId, models: w.models, reason: w.reason, retryWaveId: unit.waveId,
48
50
  // 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
+ // (the partial arm names ONE seat, so it carries the SCALAR `seatId` below instead —
52
+ // the reason given here for emitting nothing at all, that `seat-unbound` had no
53
+ // consumer, stopped being true in v4.9 W9, which gave it three).
51
54
  // ⚠️ An unidentified slot emits `null`, NEVER the alias. Collapsing it onto the alias
52
55
  // makes it indistinguishable from a second reference to that alias, and no consumer
53
56
  // can recover the difference — deadSeats has no per-alias seat count. That collapse
54
57
  // is what made two distinct dead twins render as a single row.
55
- ...(partial ? { seat: (w.models || [])[0] } : {
58
+ // v4.9 W9 P1: the fifth arm's seat identity. A SCALAR `seatId`, matching the two leg
59
+ // arms' vocabulary — this arm names exactly ONE seat, so the dead-wave arm's parallel
60
+ // ARRAY would be a second spelling of the same fact. Same null discipline as that
61
+ // array: an unidentified slot emits `null`, never the alias.
62
+ ...(partial ? { seat: (w.models || [])[0],
63
+ seatId: ((w.seats || [])[0] && (w.seats || [])[0].id) || null } : {
56
64
  seats: (w.models || []).map((m, i) => {
57
65
  const so = (w.seats || [])[i];
58
66
  return so ? so.id : null;
@@ -60,13 +68,61 @@ function waveStillDeadNote(w, unit) {
60
68
  }) } };
61
69
  }
62
70
 
71
+ /**
72
+ * The retry pass never ATTEMPTED this wave (run-retry.js's two `skipped` arms: an unmappable
73
+ * or zero-model unit, or `ctx.overBudget()`). Lifted out of run-stages.js's emit loop in the
74
+ * v4.9 W9 fix round so it sits beside `waveStillDeadNote`, whose partial arm it mirrors: two
75
+ * spellings of ONE record shape in two files is what let them drift apart in the first place.
76
+ *
77
+ * ⚠️ Carries NO `retryWaveId`, and must not: no retry wave was ever launched, so naming one
78
+ * would be a false statement about spend, and the two Workspace renderers read exactly that
79
+ * field to decide the 'retried once' phrasing.
80
+ *
81
+ * v4.9 W9 fix round 1 (council A1/C1). The partial arm previously carried `{waveId, models,
82
+ * reason, seat}` and no retry-family field at all, so all three W9 consumers
83
+ * (`live-dead-seats.js :: isSeatLoss`, `workspace-seats.js :: retriedSeats`,
84
+ * `verdict-seat-loss.js :: deriveSeatLoss`) dropped a genuinely dead seat — residual R-W9a,
85
+ * pinned known-wrong and escalated. It now emits the two facts it has ALREADY:
86
+ * `seatId` — from the record's own `seats[0]` (stage1-bind.js :: missingSeatDeadWave carries
87
+ * it), same null discipline as the arms above: an unidentified slot emits `null`,
88
+ * NEVER the alias.
89
+ * `firstFailure` — the canonical `run-retry-group.js :: recordFailure` shape for a partial
90
+ * wave (`class: 'missing'`, the record's own waveId/reason). It restates this
91
+ * record's first-pass loss and claims nothing about a retry, which is what opens
92
+ * the consumers' retry-family gate WITHOUT loosening it: orphan-leg, re-vote and
93
+ * Stage-2 judge notes still carry neither field and stay excluded.
94
+ */
95
+ function skippedWaveNote(d) {
96
+ const partial = !!d.partial;
97
+ const alias = (d.models || [])[0];
98
+ return {
99
+ channel: partial ? 'seat-unbound' : 'dead-wave',
100
+ // A `partial` record is one seat of a wave that DID produce legs, so the plain dead-wave
101
+ // sentence would be false. Everything below `models` rides on that shape ONLY: adding any
102
+ // of it unconditionally breaks an exact toEqual on a real dead wave.
103
+ what: partial
104
+ ? `seat ${alias} did not review`
105
+ : `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
106
+ why: d.reason,
107
+ effect: 'Those seats are NOT in this council. The run continues with the bench that did '
108
+ + 'launch and will exit degraded (2)',
109
+ data: { waveId: d.waveId, models: d.models, reason: d.reason,
110
+ ...(partial ? { seat: alias,
111
+ seatId: ((d.seats || [])[0] && (d.seats || [])[0].id) || null,
112
+ firstFailure: { seat: alias, class: 'missing', waveId: d.waveId, reason: d.reason },
113
+ } : {}) },
114
+ };
115
+ }
116
+
63
117
  /**
64
118
  * Leg-origin, retry wave died wholesale (bench-batch case).
65
119
  *
66
120
  * 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.
121
+ * never bound. It is a SEPARATE key from `seat`, which stays the ALIAS —
122
+ * `verdict-seat-loss.js :: deriveSeatLoss`'s `criticLeg` lookup compares `data.seat` against
123
+ * `o.critic`, an alias, so re-pointing it breaks critic-loss detection. Cited by SYMBOL, not
124
+ * line: the old `verdict.js:72` had already slid one line off that comparison, and the
125
+ * function has since left verdict.js entirely. Add a key; never repurpose that one.
70
126
  */
71
127
  function srcLegStillDeadNote(leg, unit, counts, seatId = null) {
72
128
  const seat = leg.modelInput || leg.model;
@@ -123,4 +179,5 @@ function missingLegStillDeadNote(seat, ff, unit, counts) {
123
179
  data: { seat, status: null, reason: null, firstFailure: ff, retryWaveId: unit.waveId } };
124
180
  }
125
181
 
126
- module.exports = { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote };
182
+ module.exports = { waveStillDeadNote, skippedWaveNote, srcLegStillDeadNote,
183
+ retryLegStillDeadNote, missingLegStillDeadNote };
@@ -14,7 +14,7 @@ const { buildSeats } = require('./seats');
14
14
  /** Launch all Stage-1 legs (wave + critic/lens solos), collect run docs. */
15
15
  async function launchStage1(ctx) {
16
16
  const { o, launchers } = ctx;
17
- // Seat identity for THIS launch. run.js:133 sets o.seats from asm.preflightSeats;
17
+ // Seat identity for THIS launch. run.js:142 sets o.seats from asm.preflightSeats;
18
18
  // buildSeats is pure and total, so a direct require() caller or a legacy run dir
19
19
  // reconstructs the same table rather than binding nothing (spec §4.3).
20
20
  const seats = Array.isArray(o.seats) && o.seats.length > 0
@@ -47,7 +47,7 @@ async function launchStage1(ctx) {
47
47
  seated.push({ waveId, models: [m], roster: seats.slice(i, i + 1) });
48
48
  launches.push(launchers.launchSolo({
49
49
  ...common, model: m, waveId, seats: seated[seated.length - 1].roster,
50
- prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
50
+ prompt: briefings.stage1LensBriefing(o.intent, { lens: o.lenses[i], briefing: o.briefing, date: o.date }),
51
51
  }));
52
52
  });
53
53
  } else {
@@ -61,7 +61,7 @@ async function launchStage1(ctx) {
61
61
  roster: seats.filter(s => s.alias !== o.critic) });
62
62
  launches.push(launchers.launchWave({
63
63
  ...common, models: seats1, waveId: `${o.runId}-s1`, seats: seated[seated.length - 1].roster,
64
- prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
64
+ prompt: briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date }),
65
65
  }));
66
66
  }
67
67
  if (o.critic) {
@@ -70,7 +70,7 @@ async function launchStage1(ctx) {
70
70
  roster: seats.filter(s => s.alias === o.critic).slice(0, 1) });
71
71
  launches.push(launchers.launchSolo({
72
72
  ...common, model: o.critic, waveId: `${o.runId}-c1`, seats: seated[seated.length - 1].roster,
73
- prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
73
+ prompt: briefings.stage1CriticBriefing(o.intent, { briefing: o.briefing, date: o.date }),
74
74
  }));
75
75
  }
76
76
  }
@@ -1,7 +1,7 @@
1
1
  // src/council/run-stage1-rows.js
2
2
  'use strict';
3
3
  // Superseded-seat rows + primary-error dead-seat rows (v4.7 D2/E4), moved
4
- // verbatim from run-stages.js:230-279 (v4.8 PR0 size-gate split, zero
4
+ // verbatim from run-stages.js:218-267 (v4.8 PR0 size-gate split, zero
5
5
  // behavior). The code ran inline in runStage1; it is now a function.
6
6
  // roleFor and seatOf are PARAMETERS, not requires — requiring them back from
7
7
  // run-stages would recreate the parent-child cycle that file's tail comment
@@ -10,8 +10,8 @@ const { buildRunStatsEntry } = require('./run-assemble');
10
10
  // The one keyspace. It lives in ./run-retry-keys (v4.8 Phase 2 T-A1), which IS require-free;
11
11
  // run-retry-group.js re-exports it and requires nothing else, so this import's closure
12
12
  // terminates at a leaf and cannot re-create the parent-child cycle the header above
13
- // documents eliminating.
14
- const { twinAliases, legLossKey } = require('./run-retry-group');
13
+ // documents eliminating. (v4.9 W3, SI-DUP b: `seatKey` joined this destructure — see keyOf.)
14
+ const { seatKey, twinAliases, legLossKey } = require('./run-retry-group');
15
15
  // v4.8 T-A6 size-gate split: the SUPERSEDED half of these rows moved to
16
16
  // ./run-stage1-superseded, taking `../utils/degrade` and the default stderr sink with it.
17
17
  // `degrade` is still a parameter HERE, forwarded unchanged, so a caller that omits it still
@@ -63,8 +63,11 @@ const { supersededRows } = require('./run-stage1-superseded');
63
63
  function pushDeadSeatRows({ o, retry, deadLegs0, stillDeadLegs, stillDeadWaves, extraRows,
64
64
  roleFor, seatOf, degrade, twins = twinAliases(o.seats) }) {
65
65
  // A leg's join key: its bound seat's id, else its alias — the same fallback
66
- // `run-retry-keys.js :: seatKey` (imported by run-retry.js since v4.8 T-A1) uses
67
- // for a roster slot it could not identify, so both sides of every lookup agree.
66
+ // `run-retry-keys.js :: seatKey` (imported by run-retry.js since v4.8 T-A1, and by
67
+ // this file since v4.9 W3 for the dead-wave slot join below) uses for a roster slot
68
+ // it could not identify, so both sides of every lookup agree. keyOf itself stays
69
+ // hand-written on purpose: its else branch is `l.modelInput || l.model`, a LEG read
70
+ // with no bare alias in scope — a sibling form, not a re-spelling of that rule.
68
71
  const keyOf = (leg) => { const s = seatOf.get(leg); return s ? s.id : (leg.modelInput || leg.model); };
69
72
  // v4.8 T2.2 ruling R2, the LEG arms' MINT branch: on an alias the roster repeats, an
70
73
  // unbound leg's `keyOf` names BOTH twins, so N dead legs collapsed into ONE row.
@@ -139,7 +142,7 @@ function pushDeadSeatRows({ o, retry, deadLegs0, stillDeadLegs, stillDeadWaves,
139
142
  // reconcile, so index-zipping them is safe here and nowhere else.
140
143
  (w.models || []).forEach((alias, i) => {
141
144
  const s = (w.seats || [])[i] || null;
142
- const join = s ? s.id : alias;
145
+ const join = seatKey(s, alias);
143
146
  // R2's MARK branch. This arm has no leg to mint from, and `(waveId, i)` is
144
147
  // measurably NOT unique — missingSeatDeadWave emits several records under one
145
148
  // waveId each with i === 0, and run-retry.js re-indexes when it narrows a
@@ -30,6 +30,63 @@ const { buildRunStatsEntry } = require('./run-assemble');
30
30
  const { artifactName } = require('./seats');
31
31
  const { orphanLegNote, bindPaddedWave } = require('./stage1-bind');
32
32
 
33
+ /**
34
+ * Bind the -s2 wave's legs to the seats `reviews[]` holds and note the two
35
+ * unbindable shapes (orphan legs, unbound seats) on ctx.degrade. Lifted
36
+ * verbatim out of `runStage2` below (v4.9 W2, the SI-16 function-length
37
+ * split); the caller reads back only `judgeSeatOf` — `s2WaveId` rides along
38
+ * in the return for the seam's shape, nothing downstream consumes it.
39
+ * @param {object} ctx
40
+ * @param {{reviews: Array, judges: Array<string>, s2Legs: Array<object>,
41
+ * runId: string}} args — `s2Legs` arrives pre-guarded
42
+ * (`(wave && wave.legs) || []`): the wave-null guard stays at the call
43
+ * site, beside the leg loop that shares it.
44
+ * @returns {{s2WaveId: string, judgeSeatOf: Map<object, object>}}
45
+ */
46
+ function bindStage2Seats(ctx, { reviews, judges, s2Legs, runId }) {
47
+ // v4.8 PR3 Task 4: bind the -s2 wave's legs to the SAME seats reviews[] holds,
48
+ // in `reviews` order — judges is built from that same array by the same
49
+ // `.map`, and real fanout stamps `${waveId}-${i+1}` off that same index
50
+ // (sidecar/leg-ids.js), so this holds even after an SL-2 heal (recovered legs
51
+ // are appended, run-stages.js:141, so `reviews` order is no longer seat order
52
+ // — both arrays still derive from the same `reviews`).
53
+ // §3.4's padding pattern now lives in `stage1-bind.js :: bindPaddedWave`
54
+ // (v4.8 SI-27) — why the roster is padded rather than filtered, and why
55
+ // placeholders are tracked by IDENTITY rather than an id-name prefix test,
56
+ // are in that function's docblock. Only this site's TAIL stays here.
57
+ const s2WaveId = `${runId}-s2`;
58
+ const { seatOf: judgeSeatOf, bindRes, placeholders } =
59
+ bindPaddedWave(s2WaveId, reviews.map(r => r.seat), i => judges[i], s2Legs);
60
+ // An orphan leg (a judge DID land, but no roster slot claims it) gets the
61
+ // same shape as Stage 1's orphanLegNote — `data.legId` present.
62
+ for (const leg of bindRes.orphanLegs) { ctx.degrade.note(orphanLegNote(s2WaveId, leg)); }
63
+ // review F(important): mirrors stage1-bind.js:40's suppression rule
64
+ // verbatim. A wave that returned ZERO legs is already announced on a louder
65
+ // channel (thin-cross-review, or this refusal itself) — there is no
66
+ // "missing seat" fact this adds. An orphan leg means a judge DID land for
67
+ // SOME seat we could not name — reporting the roster's other unbound seats
68
+ // as "missing" would double-count that one failure as two, on a channel
69
+ // whose whole contract is "nothing was guessed": the stray leg might BE the
70
+ // seat this loop would otherwise call missing.
71
+ if (s2Legs.length > 0 && bindRes.orphanLegs.length === 0) {
72
+ for (const seat of bindRes.unbound) {
73
+ if (placeholders.has(seat)) { continue; }
74
+ ctx.degrade.note({
75
+ channel: 'seat-unbound',
76
+ what: `leg for seat ${seat.alias} in wave ${s2WaveId} never returned`,
77
+ // R27-5: `reviews.length` IS the padded roster's length — bindPaddedWave
78
+ // maps 1:1 over the source array, and `Array.prototype.map` preserves
79
+ // length. Pinned (`ROSTERLEN`) in run-stages.test.js, because a
80
+ // substitution into unpinned prose is how a true sentence goes quietly false.
81
+ why: `the wave returned fewer judge legs than its roster of ${reviews.length}`,
82
+ effect: 'That seat did not judge; nothing was guessed and nothing was dropped',
83
+ data: { waveId: s2WaveId, seat: seat.alias },
84
+ });
85
+ }
86
+ }
87
+ return { s2WaveId, judgeSeatOf };
88
+ }
89
+
33
90
  /**
34
91
  * Stage 2: shared anonymized bundle → judge wave in _scratch → parse + repair.
35
92
  * @param {object} ctx
@@ -54,7 +111,14 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
54
111
  const labeled = reviews
55
112
  .map((r, i) => ({ label: labels.entries[i].label, text: r.text }))
56
113
  .concat(extraLabeled);
57
- const bundle = stage2.buildJudgeBundle({ reviews: labeled, findings: globalFindings, date: o.date });
114
+ // v4.9 W7: one dispatch, both intents (briefings-stage2.js :: judgeBundleFor —
115
+ // 'task' | absent, fail-closed). `briefing` is the EXTRA argument the task
116
+ // bundle needs and the review bundle ignores: task judges rank "which response
117
+ // best does the work the briefing asked for", which is unanswerable without the
118
+ // ask (spec §5.4). It rides `o.briefing` — the same field the Stage-1
119
+ // dispatchers compose from — so the text is never re-read off disk.
120
+ const bundle = stage2.judgeBundleFor(o.intent,
121
+ { reviews: labeled, findings: globalFindings, date: o.date, briefing: o.briefing });
58
122
  fs.writeFileSync(path.join(o.runDir, 'bundle-stage2.md'), bundle, { mode: 0o600 });
59
123
 
60
124
  // ROSTER, not bundle: derived ONLY from legs that actually ran, so a file-sourced
@@ -76,58 +140,23 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
76
140
  ctx.addWave(wave);
77
141
  if (isAbortExit(exitCode)) { return { aborted: exitCode, judgeResults: [], extraRows: [] }; }
78
142
 
79
- // v4.8 PR3 Task 4: bind the -s2 wave's legs to the SAME seats reviews[] holds,
80
- // in `reviews` order — judges is built from that same array by the same
81
- // `.map`, and real fanout stamps `${waveId}-${i+1}` off that same index
82
- // (sidecar/leg-ids.js), so this holds even after an SL-2 heal (recovered legs
83
- // are appended, run-stages.js:153, so `reviews` order is no longer seat order
84
- // — both arrays still derive from the same `reviews`).
85
- // §3.4's padding pattern now lives in `stage1-bind.js :: bindPaddedWave`
86
- // (v4.8 SI-27) — why the roster is padded rather than filtered, and why
87
- // placeholders are tracked by IDENTITY rather than an id-name prefix test,
88
- // are in that function's docblock. Only this site's TAIL stays here.
89
- const s2WaveId = `${o.runId}-s2`;
143
+ // Seat binding (v4.8 PR3 Task 4) lives in bindStage2Seats above (v4.9 W2).
90
144
  // review F(critical): `wave` is legitimately null — a budget/args refusal
91
145
  // (run-budget.js failPre) returns `{wave: null, exitCode: 1}`, which
92
146
  // isAbortExit does NOT catch (only 130/143), so execution reaches here.
93
147
  // Guarded exactly like the leg loop below (`(wave && wave.legs) || []`) —
94
148
  // this is the only OTHER dereference of wave.legs in the file.
95
149
  const s2Legs = (wave && wave.legs) || [];
96
- const { seatOf: judgeSeatOf, bindRes, placeholders } =
97
- bindPaddedWave(s2WaveId, reviews.map(r => r.seat), i => judges[i], s2Legs);
98
- // An orphan leg (a judge DID land, but no roster slot claims it) gets the
99
- // same shape as Stage 1's orphanLegNote — `data.legId` present.
100
- for (const leg of bindRes.orphanLegs) { ctx.degrade.note(orphanLegNote(s2WaveId, leg)); }
101
- // review F(important): mirrors stage1-bind.js:40's suppression rule
102
- // verbatim. A wave that returned ZERO legs is already announced on a louder
103
- // channel (thin-cross-review, or this refusal itself) — there is no
104
- // "missing seat" fact this adds. An orphan leg means a judge DID land for
105
- // SOME seat we could not name — reporting the roster's other unbound seats
106
- // as "missing" would double-count that one failure as two, on a channel
107
- // whose whole contract is "nothing was guessed": the stray leg might BE the
108
- // seat this loop would otherwise call missing.
109
- if (s2Legs.length > 0 && bindRes.orphanLegs.length === 0) {
110
- for (const seat of bindRes.unbound) {
111
- if (placeholders.has(seat)) { continue; }
112
- ctx.degrade.note({
113
- channel: 'seat-unbound',
114
- what: `leg for seat ${seat.alias} in wave ${s2WaveId} never returned`,
115
- // R27-5: `reviews.length` IS the padded roster's length — bindPaddedWave
116
- // maps 1:1 over the source array, and `Array.prototype.map` preserves
117
- // length. Pinned (`ROSTERLEN`) in run-stages.test.js, because a
118
- // substitution into unpinned prose is how a true sentence goes quietly false.
119
- why: `the wave returned fewer judge legs than its roster of ${reviews.length}`,
120
- effect: 'That seat did not judge; nothing was guessed and nothing was dropped',
121
- data: { waveId: s2WaveId, seat: seat.alias },
122
- });
123
- }
124
- }
150
+ const { judgeSeatOf } = bindStage2Seats(ctx, { reviews, judges, s2Legs, runId: o.runId });
125
151
 
126
152
  const judgeResults = [];
127
153
  // v4.7 D2: every judge-repair launch is a billed leg of its own, distinct from
128
154
  // the judge's original Stage-2 wave leg it is trying to fix — it gets its own
129
155
  // row so its cost is never folded into, or lost from, the judge's row (mirrors
130
- // runStage1's -p<N> extraRows, ./run-stages.js:117-120).
156
+ // runStage1's -p<N> extraRows, run-stages.js :: runStage1). ⚠️ Cited by LINE until the v4.9 W9
157
+ // fix round re-opened it: run-stages.js@5830ece3:117-120 was the skipped-wave note's `what`
158
+ // arm, not extraRows at all, and it has since moved to run-retry-notes.js :: skippedWaveNote.
159
+ // Anchored by symbol now, per the anti-rot rule.
131
160
  const extraRows = [];
132
161
  let repairSeq = 0;
133
162
  for (const leg of (wave && wave.legs) || []) {
@@ -159,7 +188,12 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
159
188
  runState.appendStageWave(o.runDir, 'stage2', waveId);
160
189
  const solo = await ctx.launchers.launchSolo({
161
190
  model: judge,
162
- prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors, judgement: judging }),
191
+ // v4.9 W7 fix round (F1): the SAME intent channel the bundle dispatch
192
+ // above rides. A judge briefed on the task contract must be repaired
193
+ // against the task contract — a repair solo is a fresh session, so the
194
+ // contract embedded here is the only output shape it ever sees.
195
+ prompt: stage2.judgeRepairPromptFor(o.intent,
196
+ { errors: parsed.errors, judgement: judging }),
163
197
  project: ctx.scratchDir, waveId, timeout: o.timeout,
164
198
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
165
199
  councilRunId: o.runId, councilName: o.councilName,
@@ -172,13 +206,13 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
172
206
  // is returned only for shape consistency, never read past this point.
173
207
  return { aborted: solo.exitCode, judgeResults, extraRows };
174
208
  }
175
- // Every -q<N> launch gets a row — INCLUDING a repair that failed: the
176
- // error status rides naturally off solo.leg (null/'error'-status leg ⇒
177
- // buildRunStatsEntry's own never-invent defaults), no special-casing needed.
178
- extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: judge, role: 'repair', wasChair: false }));
179
209
  const out = (solo.leg && solo.leg.summary) || '';
180
210
  if (out.trim()) { judging = out; }
181
211
  parsed = parseJudgeOutput(out, parseCtx);
212
+ // Every -q<N> launch gets a row — INCLUDING a failed repair (null/'error' leg ⇒ never-invent
213
+ // defaults); pushed AFTER the re-parse to stamp the repair LEG's own measured outcome (PR 199 D1, v4.9 V18 refined).
214
+ extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: judge, role: 'repair',
215
+ wasChair: false, conformance: parsed.ok ? 'clean' : 'unstructured' }));
182
216
  if (parsed.ok) { conformance = 'repaired'; }
183
217
  }
184
218
  if (!parsed.ok) {
@@ -27,6 +27,7 @@ const { launchStage1 } = require('./run-stage1-launch');
27
27
  const { buildRunStatsEntry } = require('./run-assemble');
28
28
  const { pushDeadSeatRows } = require('./run-stage1-rows');
29
29
  const { bindStage1Waves, orphanLegNote, missingSeatDeadWave } = require('./stage1-bind');
30
+ const { skippedWaveNote } = require('./run-retry-notes');
30
31
  // slug lives in ./seats (v4.8 PR1) so that module can stay require-free;
31
32
  // re-exported below — run-stages.test.js imports it from here.
32
33
  const { slug } = require('./seats');
@@ -108,22 +109,9 @@ async function runStage1(ctx) {
108
109
  }).filter(w => w.models.length > 0) };
109
110
  }
110
111
 
111
- for (const d of retry.skippedDeadWaves) {
112
- // A `partial` record is one seat of a wave that DID produce legs, so the
113
- // plain dead-wave sentence would be false. `seat` rides only on that shape:
114
- // adding it unconditionally breaks an exact toEqual on a real dead wave.
115
- ctx.degrade.note({
116
- channel: d.partial ? 'seat-unbound' : 'dead-wave',
117
- what: d.partial
118
- ? `seat ${(d.models || [])[0]} did not review`
119
- : `Stage-1 wave ${d.waveId} (${d.models.join(', ') || 'no models'}) produced NO legs`,
120
- why: d.reason,
121
- effect: 'Those seats are NOT in this council. The run continues with the bench that did '
122
- + 'launch and will exit degraded (2)',
123
- data: { waveId: d.waveId, models: d.models, reason: d.reason,
124
- ...(d.partial ? { seat: (d.models || [])[0] } : {}) },
125
- });
126
- }
112
+ // Same shape as `stillDeadNotes` below: the builder lives in run-retry-notes.js (v4.9 W9
113
+ // fix round), beside `waveStillDeadNote` whose partial arm it mirrors; this file EMITS.
114
+ for (const d of retry.skippedDeadWaves) { ctx.degrade.note(skippedWaveNote(d)); }
127
115
  for (const leg of retry.skippedDeadLegs) {
128
116
  ctx.degrade.note({
129
117
  channel: 'dead-leg',
@@ -197,7 +185,7 @@ async function runStage1(ctx) {
197
185
  runState.appendStageWave(o.runDir, 'stage1', waveId);
198
186
  const solo = await ctx.launchers.launchSolo({
199
187
  model: m.modelInput,
200
- prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors, review: repairing }),
188
+ prompt: briefings.stage1RepairPrompt(o.intent, { errors: res.errors, review: repairing }),
201
189
  project: o.runDir, waveId, timeout: o.timeout,
202
190
  gateway: o.gateway, noValidateModel: o.noValidateModel, noCostGate: o.noCostGate,
203
191
  councilRunId: o.runId, councilName: o.councilName,
@@ -216,13 +204,13 @@ async function runStage1(ctx) {
216
204
  return { aborted: solo.exitCode, reviews, deadLegs: stillDeadLegs, deadWaves: stillDeadWaves,
217
205
  degraded: false, extraRows };
218
206
  }
219
- // Every -p<N> launch gets a row — INCLUDING a repair that failed: the
220
- // error status rides naturally off solo.leg (null/'error'-status leg ⇒
221
- // buildRunStatsEntry's own never-invent defaults), no special-casing needed.
222
- extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: m.modelInput, role: 'repair', wasChair: false }));
223
207
  const repaired = (solo.leg && solo.leg.summary) || '';
224
208
  if (repaired.trim()) { repairing = repaired; }
225
209
  res = validateFindings(repaired);
210
+ // Every -p<N> launch gets a row — INCLUDING a failed repair (null/'error' leg ⇒ never-invent
211
+ // defaults); pushed AFTER re-validation to stamp the repair LEG's own measured outcome (PR 199 D1, v4.9 V18 refined).
212
+ extraRows.push(buildRunStatsEntry({ leg: solo.leg, model: m.modelInput, role: 'repair',
213
+ wasChair: false, conformance: res.ok ? 'clean' : 'unstructured' }));
226
214
  if (res.ok) { conformance = 'repaired'; }
227
215
  }
228
216
  if (!res.ok) { conformance = 'unstructured'; }
@@ -40,9 +40,37 @@
40
40
  * `seat` (v4.8 PR4c §3.1) is the seat OBJECT — {id, alias, role, lens, position}
41
41
  * or null — never an id string. Callers pass `r.seat` / the dead-seat loop's own
42
42
  * `seat` verbatim, so the object IS the contract instead of a prose one.
43
+ *
44
+ * `summary` (v4.9 W11 / PR1F-2) is the one field the folded
45
+ * `run-debate-revote.js :: legRow` needed that this entry did not emit. It is
46
+ * EXPLICIT-ONLY and emit-when-set: it is deliberately NOT sourced from
47
+ * `leg.summary`, because that is the model's raw review prose and no runStats
48
+ * row has ever carried it — a leg-sourced default would push review text into
49
+ * tally-input.json for every existing caller. It stops there: `tally.js ::
50
+ * tally`'s re-projection allowlist does not name `summary`, so it reaches
51
+ * neither tally.json/verdict.json nor the ledger (MEASURED). Passing nothing
52
+ * leaves every row byte-for-byte unchanged (pins G4a/G4b/G4c,
53
+ * tests/council/runstats-byte-order.test.js).
54
+ *
55
+ * `ttftMs` (v4.9 W13 Task A) is time-to-first-token for this row's leg, read off
56
+ * the leg document and emitted only when it is a NON-NEGATIVE INTEGER — the
57
+ * shape council-tally.schema.json declares, and (PR #207 round 3, B3) a stricter
58
+ * test than the bare type check this used to spell. PROBE ONLY — nothing
59
+ * derives a backstop, threshold, or routing decision from it yet (ruling R12:
60
+ * probe first, derive later). Absent means no substantive tick was observed —
61
+ * or that the only reading taken was not an honest measurement — which is
62
+ * neither `0` (a real measurement) nor `null`.
43
63
  */
44
64
  function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsUnverified,
45
- repairRefused, seat }) {
65
+ repairRefused, seat, summary }) {
66
+ // v4.9 W13 Task A: the TTFT probe's last hop, read off the LEG document the
67
+ // same way `waveId` and `resolvedModel` below are. DEVIATION from the plan's
68
+ // literal "thread from the leg at the callers": ten call sites across seven
69
+ // council modules hold a leg, and threading a parameter through each would
70
+ // (a) reach outside this task's file scope and (b) make a forgotten caller a
71
+ // SILENT gap. Sourcing it here covers every caller that holds a leg by
72
+ // construction, and a dead seat (`leg: null`) still carries no key.
73
+ const ttftMs = leg ? leg.ttftMs : undefined;
46
74
  return {
47
75
  model: model !== undefined ? model : (leg ? leg.model : null),
48
76
  role,
@@ -50,6 +78,7 @@ function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsU
50
78
  conformance: conformance || 'clean',
51
79
  ...(findingsUnverified ? { findingsUnverified: true } : {}),
52
80
  ...(repairRefused ? { repairRefused } : {}),
81
+ ...(summary ? { summary } : {}),
53
82
  ...(leg && leg.waveId ? { waveId: leg.waveId } : {}),
54
83
  ...(leg && leg.model ? { resolvedModel: leg.model } : {}),
55
84
  // v4.8 PR4c §3.1 / R4c-9: emit-when-DIFFERENT, compared against the seat's
@@ -64,6 +93,22 @@ function buildRunStatsEntry({ leg, model, role, wasChair, conformance, findingsU
64
93
  ...(seat && seat.id !== seat.alias ? { seat: seat.id } : {}),
65
94
  status: leg ? leg.status : 'error',
66
95
  durationMs: leg && typeof leg.durationMs === 'number' ? leg.durationMs : null,
96
+ // Emit-when-set, NOT `durationMs`'s null-coercion one line above: this row
97
+ // is the C2 derivation's future input, and a null there would be read as a
98
+ // measurement. Absent means "never observed" and must stay absent.
99
+ //
100
+ // PR #207 round 3 (B3): emit-when-VALID too. The shared predicate is
101
+ // `src/utils/ttft.js :: isMeasuredTtft`, and it is spelled out by HAND here
102
+ // for one reason only — the module invariant at the top of this file forbids
103
+ // importing anything, and the pin that enforces it fires on the character
104
+ // sequence anywhere in the file, comments included. The structural pins that
105
+ // keep this copy in step with the shared one are in this module's own test
106
+ // file. Do not "simplify" it back to a bare type test: that admits NaN and
107
+ // ±Infinity (both of which serialize to `null`), negatives from a backward
108
+ // wall-clock jump at the probe, and fractions from a hand-edited artifact —
109
+ // every one of them forbidden by council-tally.schema.json's
110
+ // `integer, minimum 0`.
111
+ ...(Number.isInteger(ttftMs) && ttftMs >= 0 ? { ttftMs } : {}),
67
112
  usage: (leg && leg.usage) || null,
68
113
  };
69
114
  }
@@ -119,6 +119,15 @@ async function runCouncil(options, deps = {}) {
119
119
  const ctx = { o, launchers, addWave, overBudget, degrade, scratchDir: path.join(o.runDir, '_scratch') };
120
120
 
121
121
  try {
122
+ // v4.9 W5.3: o.intent is 'task' or ABSENT. ⚠️ PR #200 A3 (round-3 ruling): 'review' — the default spelled out loud, which both transports already strip (cli-handlers-council-run.js, mcp-council-run.js) — NORMALIZES to absent here rather than being refused, because runCouncil is a public door too and one input must not mean two things at two doors. `delete`, never `= undefined` (a present-but-undefined key still changes JSON output paths — the T10 lesson). Only a genuinely unknown value is BAD_ARGS. Pinned: tests/council/run-intent.test.js. ⚠️ Round-4 C2 (that this normalization runs AFTER the seed and so materializes intent:'review' on run.json) is REFUTED BY MEASUREMENT, not by argument: the ordering is real — `runState.initCouncilRun` runs ~50 lines above — but that seed literal (`run-state.js :: initCouncilRun`) has no `intent` key to copy, and BOTH run.json writers of it (the checkpoint below, and `cli-handlers-council-run.js`'s) are `=== 'task'` spreads that 'review' cannot pass. Mutant SEEDCOPY (a `...(o.intent ? {intent:o.intent} : {})` added to that seed) reddens exactly the pin, so no reordering is needed here.
123
+ if (o.intent === 'review') { delete o.intent; } else if (o.intent !== undefined && o.intent !== 'task') {
124
+ return finalize(1, { code: 'BAD_ARGS',
125
+ message: `Error: intent must be 'task' or 'review' (omitted means review); got '${o.intent}'` });
126
+ }
127
+ if (o.intent === 'task' && o.claudeReviewFile) { // V12: a file review is REVIEW machinery
128
+ return finalize(1, { code: 'BAD_ARGS', message:
129
+ '--claude-review enters a REVIEW as review N+1 and has no task-mode meaning; drop it for a task run' });
130
+ }
122
131
  // v4.1 §4.4: Claude-in-council is a FILE input — validated after initRun (so the
123
132
  // error doc lands in a run dir that exists) and before any launch (zero spend).
124
133
  const pre = asm.preflightClaudeReview(o);
@@ -132,11 +141,12 @@ async function runCouncil(options, deps = {}) {
132
141
  if (seatPre.error) { return finalize(1, seatPre.error); }
133
142
  o.seats = seatPre.seats;
134
143
  o.criticSeat = seatPre.criticSeat;
135
- runState.checkpoint(o.runDir, { seats: o.seats, criticSeat: o.criticSeat });
144
+ runState.checkpoint(o.runDir, { seats: o.seats, criticSeat: o.criticSeat,
145
+ ...(o.intent === 'task' ? { intent: 'task' } : {}) }); // v4.9 W5.3: emit-when-'task', never 'review'
136
146
 
137
147
  // Composed Stage-1 seat briefing persisted for auditability (spec §4 layout).
138
148
  fs.writeFileSync(path.join(o.runDir, 'briefing-stage1.md'),
139
- briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }), { mode: 0o600 });
149
+ briefings.stage1SeatBriefing(o.intent, { briefing: o.briefing, date: o.date }), { mode: 0o600 });
140
150
 
141
151
  // ---- Stage 1: independent reviews ----
142
152
  // Lens mode launches one solo per seat instead of a `-s1` seat wave, so it
@@ -229,7 +239,7 @@ async function runCouncil(options, deps = {}) {
229
239
  // falls back to r.model on a miss rather than assuming the two arrays stay
230
240
  // symmetric — a naive seatKey on both sides would make that orphan's
231
241
  // conformance unreachable instead of merged, a silent total loss.
232
- const seatKey = (s, alias) => (s ? s.id : alias);
242
+ const { seatKey } = require('./run-retry-keys'); // v4.9 W3 (SI-DUP b): the one rule, one home
233
243
  const byJudge = new Map(s2.judgeResults.map(j => [seatKey(j.seat, j.judge), j]));
234
244
  for (const r of s1.reviews) {
235
245
  const j = byJudge.get(r.seat ? r.seat.id : r.model) || byJudge.get(r.model);
@@ -242,16 +252,21 @@ async function runCouncil(options, deps = {}) {
242
252
  // buildTallyInput appends after the primary review rows (run-assemble.js
243
253
  // docblock). Neither stage invents a second mechanism for the other's kind
244
254
  // of row.
245
- const mkInput = (chairStats, chairModel) => asm.buildTallyInput({
246
- runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
247
- reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
248
- extraRows: [...s1.extraRows, ...s2.extraRows],
249
- // v4.8 PR4c §3.2: the ONLY production caller of buildTallyInput, so this is
250
- // the single seam meta.seats enters through. The final input is derived by
251
- // SPREAD (run-finish.js:36), not rebuilt, so both the provisional and the
252
- // debated input inherit it from here.
253
- seats: o.seats,
254
- });
255
+ const mkInput = (chairStats, chairModel) => {
256
+ const input = asm.buildTallyInput({
257
+ runId: o.runId, date: o.date, bench: o.models.slice(), chair: chairModel,
258
+ reviews: s1.reviews, judgeResults: s2.judgeResults, chairStats, claudeReview,
259
+ extraRows: [...s1.extraRows, ...s2.extraRows],
260
+ // v4.8 PR4c §3.2: the ONLY production caller of buildTallyInput, so this is
261
+ // the single seam meta.seats enters through. The final input is derived by
262
+ // SPREAD (run-finish.js:36), not rebuilt, so both the provisional and the
263
+ // debated input inherit it from here.
264
+ seats: o.seats,
265
+ });
266
+ // v4.9 W5.3: emit-when-'task' as a pure meta TAIL; review meta stays untouched.
267
+ if (o.intent === 'task') { input.meta = { ...input.meta, intent: 'task' }; }
268
+ return input;
269
+ };
255
270
  const provisionalInput = mkInput(null, o.chair);
256
271
  const provisional = tally(provisionalInput);
257
272
 
@@ -33,7 +33,7 @@ function sanitizeName(model) {
33
33
  * Roles follow run-stages.js roleFor's PRECEDENCE — under lenses every seat is
34
34
  * a lens seat and 'critic' is unreachable — and match it for every bench the
35
35
  * CLI can produce (no repeated alias; lenses.length === bench.length, enforced
36
- * at cli-handlers-council-run.js:161). They DELIBERATELY diverge where roleFor
36
+ * at cli-handlers-council-run.js:164). They DELIBERATELY diverge where roleFor
37
37
  * is wrong: roles here are POSITIONAL, so twins under lenses get their own
38
38
  * lens instead of both getting the first twin's (roleFor uses
39
39
  * o.models.indexOf). An empty lenses array counts as no lenses (roleFor treats
@@ -73,7 +73,7 @@ function buildSeats(bench, critic, lenses) {
73
73
  /**
74
74
  * Role of a seat by its id. Returns 'seat' for an unknown id, matching
75
75
  * roleFor's fallthrough for any alias that is not the critic. (roleFor returns
76
- * 'critic' for an OFF-BENCH critic because run-stages.js:40 is not gated on
76
+ * 'critic' for an OFF-BENCH critic because run-stages.js:41 is not gated on
77
77
  * bench membership; buildSeats never mints a seat for one and preflightSeats
78
78
  * rejects that state pre-spend, so 'seat' is right for every run v4.8 creates.)
79
79
  * @param {?Array<object>} seats
@@ -35,7 +35,7 @@ function bindStage1Waves(waves) {
35
35
  // either: an orphan leg is a review that LANDED — materializeReviews writes it
36
36
  // under its alias name — for a seat we cannot name. Retrying that seat would
37
37
  // buy a SECOND paid leg and put two reviews on one seat, breaking the
38
- // invariant run-stages.js:142-151 states. The orphan is already announced on
38
+ // invariant run-stages.js:130-139 states. The orphan is already announced on
39
39
  // `seat-unbound` at bind time (R-B: orphans are not a loss and not retryable).
40
40
  if (legs.length === 0 || strays.length > 0) { continue; }
41
41
  for (const seat of unbound) {
@@ -90,7 +90,8 @@ function missingSeatDeadWave(m) {
90
90
  * and the alias lookup are parameterised — site 2's callback was literally
91
91
  * `(r, i) => { if (r.seat) … }`, reading the seat off a review, not off a roster
92
92
  * slot. The three sites: `run-retry-launch.js :: bindRetryWave`,
93
- * `run-stage2.js :: runStage2` and `run-debate-revote.js :: runRevoteWave`.
93
+ * `run-stage2.js :: bindStage2Seats` (in `runStage2` itself until the v4.9 W2
94
+ * function-length split) and `run-debate-revote.js :: runRevoteWave`.
94
95
  *
95
96
  * How this differs from `bindStage1Waves` above: that one takes MANY waves, each
96
97
  * with a REAL roster — no padding, no placeholders, and it owns its own missing/