amicus 4.8.1 → 4.9.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 (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +316 -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 +41 -18
  10. package/electron/main.js +19 -5
  11. package/electron/offer-session.js +51 -0
  12. package/electron/setup-ui.js +46 -39
  13. package/electron/workspace-ui/live-dead-seats.js +163 -91
  14. package/electron/workspace-ui/workspace-banners.js +30 -7
  15. package/electron/workspace-ui/workspace-matrix.js +23 -3
  16. package/electron/workspace-ui/workspace-seats.js +95 -79
  17. package/package.json +1 -1
  18. package/schemas/council-run.schema.json +2 -2
  19. package/schemas/council-tally.schema.json +17 -1
  20. package/schemas/council-verdict.schema.json +12 -4
  21. package/schemas/run.schema.json +6 -1
  22. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  23. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  24. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  25. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  26. package/skills/second-opinion/SKILL.md +151 -36
  27. package/src/cli-council-run-bench.js +98 -6
  28. package/src/cli-handlers-council-run.js +18 -6
  29. package/src/cli-handlers-council.js +57 -7
  30. package/src/cli-handlers-doctor.js +1 -1
  31. package/src/cli.js +3 -1
  32. package/src/council/anonymize.js +2 -1
  33. package/src/council/briefings-chair-task.js +161 -0
  34. package/src/council/briefings-chair.js +33 -8
  35. package/src/council/briefings-debate.js +79 -13
  36. package/src/council/briefings-stage2-task.js +236 -0
  37. package/src/council/briefings-stage2.js +103 -26
  38. package/src/council/briefings-task.js +167 -0
  39. package/src/council/briefings.js +41 -4
  40. package/src/council/chair-fallback.js +95 -0
  41. package/src/council/debate.js +38 -21
  42. package/src/council/findings.js +3 -2
  43. package/src/council/ledger.js +2 -2
  44. package/src/council/parse-stage2.js +63 -15
  45. package/src/council/report-cost.js +61 -0
  46. package/src/council/report-html.js +26 -4
  47. package/src/council/report-md.js +30 -2
  48. package/src/council/report.js +40 -37
  49. package/src/council/run-assemble.js +21 -6
  50. package/src/council/run-chair.js +44 -95
  51. package/src/council/run-debate-revote.js +81 -49
  52. package/src/council/run-debate.js +51 -34
  53. package/src/council/run-finish.js +5 -3
  54. package/src/council/run-retry-keys.js +4 -4
  55. package/src/council/run-retry-launch.js +4 -4
  56. package/src/council/run-retry-notes.js +72 -15
  57. package/src/council/run-stage1-launch.js +4 -4
  58. package/src/council/run-stage1-rows.js +9 -6
  59. package/src/council/run-stage2.js +81 -47
  60. package/src/council/run-stages.js +9 -21
  61. package/src/council/run-stats-entry.js +46 -1
  62. package/src/council/run.js +28 -13
  63. package/src/council/seats.js +2 -2
  64. package/src/council/stage1-bind.js +3 -2
  65. package/src/council/verdict-seat-loss.js +124 -0
  66. package/src/council/verdict.js +108 -99
  67. package/src/headless.js +256 -49
  68. package/src/mcp-council-bench.js +64 -3
  69. package/src/mcp-council-run.js +10 -3
  70. package/src/mcp-server.js +52 -12
  71. package/src/mcp-tools.js +41 -5
  72. package/src/observe/council-legs.js +2 -2
  73. package/src/opencode-client.js +19 -1
  74. package/src/pack/pack-forward.js +15 -12
  75. package/src/pack/pack-resolve.js +1 -1
  76. package/src/prompt-builder.js +17 -1
  77. package/src/sidecar/fanout-leg-fallback.js +2 -1
  78. package/src/sidecar/fanout-leg.js +26 -0
  79. package/src/sidecar/fanout.js +1 -1
  80. package/src/sidecar/list-council.js +178 -0
  81. package/src/sidecar/list-limit.js +3 -1
  82. package/src/sidecar/list-search.js +2 -1
  83. package/src/sidecar/models-render.js +71 -0
  84. package/src/sidecar/models.js +19 -45
  85. package/src/sidecar/read.js +34 -10
  86. package/src/sidecar/reopen-spend.js +2 -1
  87. package/src/sidecar/setup.js +13 -4
  88. package/src/sidecar/start.js +2 -1
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +10 -3
  91. package/src/utils/alias-shadow-writer.js +220 -0
  92. package/src/utils/alias-shadow.js +294 -0
  93. package/src/utils/config.js +1 -1
  94. package/src/utils/curated-models.js +19 -9
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +2 -5
  97. package/src/utils/engine-log-parse.js +289 -0
  98. package/src/utils/engine-log-tail.js +114 -0
  99. package/src/utils/engine-log.js +250 -0
  100. package/src/utils/engine-skew-records.js +146 -0
  101. package/src/utils/engine-skew.js +300 -0
  102. package/src/utils/gateway-router.js +21 -3
  103. package/src/utils/model-canonicalization.js +55 -6
  104. package/src/utils/model-catalog.js +27 -9
  105. package/src/utils/model-fetcher.js +69 -16
  106. package/src/utils/model-shortlist.js +5 -2
  107. package/src/utils/provider-default-picker.js +6 -3
  108. package/src/utils/quick-picks.js +45 -7
  109. package/src/utils/result-schema.js +17 -1
  110. package/src/utils/text-sanitize.js +81 -0
  111. package/src/utils/ttft.js +57 -0
  112. package/src/utils/untrusted-fence.js +111 -1
  113. package/src/workspace/fold-format.js +28 -7
  114. package/src/workspace/live-normalize.js +2 -1
  115. package/src/workspace/matrix-model.js +6 -2
  116. package/src/workspace/run-detail.js +33 -7
  117. package/src/workspace/seat-space.js +10 -6
@@ -11,9 +11,8 @@ const { buildLocalSectionHTML } = require('./setup-ui-local');
11
11
  const { buildLocalScript } = require('./setup-ui-local-script');
12
12
  const { getDefaultAliases } = require('../src/utils/config');
13
13
  const { getBrandName } = require('./toolbar');
14
- const { resolveQuickPicks } = require('../src/utils/quick-picks');
14
+ const { resolveQuickPicks, canonicalRoutesFor } = require('../src/utils/quick-picks');
15
15
  const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
16
- const { listDirectProviders } = require('../src/utils/provider-registry');
17
16
 
18
17
  /**
19
18
  * @param {object} [options={}]
@@ -30,17 +29,26 @@ function buildSetupHTML(options = {}) {
30
29
  quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
31
30
  shortlists = {},
32
31
  } = options;
32
+ // Council A1 (PR 215): a pick reaching the page WITHOUT canonicalRoutes makes
33
+ // pickRouteFor fall back to the raw openrouter/... route, which this codebase
34
+ // treats as an EXPLICIT force-OpenRouter literal that never reconsiders
35
+ // direct-first -- so an offline setup (pinned fallback above, catalog
36
+ // unavailable) would pin the user to the gateway permanently. Backfill from an
37
+ // EMPTY catalog: directFormIfSafe is optimistic when nothing disproves the bare
38
+ // form (restoring direct-first) while still refusing for DIVERGENT_VENDORS,
39
+ // which the deleted toBareIfDirect did not.
40
+ const picks = quickPicks.map(p =>
41
+ (p && p.canonicalRoutes) ? p : { ...p, canonicalRoutes: canonicalRoutesFor(p, { models: [] }) });
33
42
  const brandName = getBrandName(client);
34
43
  const keysHtml = buildKeysStepHTML(PROVIDERS);
35
- const modelHtml = buildModelStepHTML(quickPicks, undefined, undefined, shortlists);
44
+ const modelHtml = buildModelStepHTML(picks, undefined, undefined, shortlists);
36
45
  const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
37
46
  const css = buildWizardCSS();
38
47
  const providersJson = JSON.stringify(PROVIDERS);
39
- const modelChoicesJson = JSON.stringify(quickPicks);
48
+ const modelChoicesJson = JSON.stringify(picks);
40
49
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
41
50
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
42
51
  const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
43
- const directProvidersJson = JSON.stringify(listDirectProviders());
44
52
  return `<!DOCTYPE html>
45
53
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
46
54
  <style>${css}</style></head><body>
@@ -62,11 +70,11 @@ function buildSetupHTML(options = {}) {
62
70
  </div>
63
71
  </div>
64
72
  <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
65
- ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson)}
73
+ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
66
74
  </body></html>`;
67
75
  }
68
76
 
69
- function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson) {
77
+ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
70
78
  const keysJs = buildKeysScript();
71
79
  const aliasJs = buildAliasScript();
72
80
  const councilJs = buildCouncilScript();
@@ -81,22 +89,26 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
81
89
  var selectedProvider = null;
82
90
  var modelChoicesData = ${modelChoicesJson};
83
91
  var providerNamesData = ${providerNamesJson};
84
- var defaultAliases = ${defaultAliasesJson};
92
+ var defaultAliases = Object.assign(Object.create(null), ${defaultAliasesJson});
85
93
  var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
86
- var directProviders = ${directProvidersJson};
87
94
  var routingChoices = {};
88
95
  var explicitRouteChoices = {};
89
96
  // issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
90
97
  // means "use the family flagship", i.e. today's behavior.
91
98
  var modelChoiceIds = {};
92
99
  var modelOpenrouterIds = {};
93
- var aliasEdits = {};
94
- var aliasDisplay = {};
100
+ // T3 (PR 199): every table keyed by USER-controlled alias names is seeded
101
+ // null-prototype, same as defaultAliases above -- an alias literally named
102
+ // __proto__ on a plain {} hits the Object.prototype setter (silent no-op
103
+ // write) and reads back Object.prototype, which then throws on .split('/')
104
+ // in updateAliasesForConfiguredKeys.
105
+ var aliasEdits = Object.create(null);
106
+ var aliasDisplay = Object.create(null);
95
107
  // N1: the alias map as loaded from disk (init below), so buildReview can
96
- // tell an actual change from a value-identical re-write. Stays {} until
108
+ // tell an actual change from a value-identical re-write. Stays empty until
97
109
  // init resolves (a fresh/offline config has nothing saved yet, so every
98
110
  // computed write IS new).
99
- var savedAliases = {};
111
+ var savedAliases = Object.create(null);
100
112
  window.availableModels = null;
101
113
  var keyValid = false, validatedKey = '';
102
114
  var $ = function(id) { return document.getElementById(id); };
@@ -160,7 +172,10 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
160
172
  // buildReview's N1 diff is not measured to be wrong today -- but the
161
173
  // copy is one line and removes a fragile "never mutate this" invariant
162
174
  // future edits would otherwise have to remember.
163
- savedAliases = Object.assign({}, cfg.aliases); // N1: buildReview diffs against this
175
+ // T3: null-prototype target -- Object.assign onto a plain {} would
176
+ // route a literal __proto__ alias key through the prototype setter
177
+ // instead of landing it as an own key.
178
+ savedAliases = Object.assign(Object.create(null), cfg.aliases); // N1: buildReview diffs against this
164
179
  modelChoicesData.forEach(function(mc) {
165
180
  var currentModel = cfg.aliases[mc.alias];
166
181
  if (currentModel) {
@@ -280,20 +295,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
280
295
  } else { nextBtn.disabled = false; }
281
296
  }
282
297
 
283
- // #61: an auto-selected (non-explicit) openrouter/<vendor>/<model> route
284
- // whose vendor also has a direct integration must be stored bare
285
- // (<vendor>/<model>) so the gateway router can policy-route it direct-first;
286
- // a stored openrouter/... string is treated as an explicit force-OpenRouter
287
- // literal and never reconsiders direct-first. Mirrors
288
- // src/utils/curated-models.js's toCanonicalDefault exactly. Gateway-only
289
- // vendors (not in directProviders, e.g. qwen/grok/glm/...) pass through
290
- // unchanged since OpenRouter is their only route anyway.
291
- function toBareIfDirect(route) {
292
- if (typeof route !== 'string' || route.indexOf('openrouter/') !== 0) { return route; }
293
- var rest = route.slice('openrouter/'.length);
294
- var vendor = rest.split('/')[0];
295
- return directProviders.indexOf(vendor) !== -1 ? rest : route;
296
- }
297
298
 
298
299
  // Single source of the route choice for a quick-pick row: explicit pill
299
300
  // choice if its key still exists, else first provider with a key, else
@@ -303,12 +304,12 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
303
304
  // issue 138: an explicit per-model choice overrides the family flagship.
304
305
  var picked = modelChoiceIds[mc.alias];
305
306
  if (picked) {
306
- // Deliberately NEVER toBareIfDirect(picked) here (unlike the auto-pick
307
- // canonicalization below): picked/modelOpenrouterIds come straight
308
- // from the shortlist's own id/data-or, and for a DIVERGENT_VENDOR
309
- // (e.g. anthropic) that id can already BE its only-callable
310
- // openrouter/<vendor>/... form -- stripping the prefix would
311
- // fabricate a direct id nothing serves.
307
+ // A drilled-down pick is returned VERBATIM -- never canonicalised (unlike
308
+ // the auto-pick below): picked/modelOpenrouterIds come straight from the
309
+ // shortlist's own id/data-or, which the picker already built through
310
+ // directFormIfSafe. For a DIVERGENT_VENDOR (e.g. anthropic) that id can
311
+ // already BE its only-callable openrouter/<vendor>/... form, so touching
312
+ // the prefix here would fabricate a direct id nothing serves.
312
313
  if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
313
314
  return modelOpenrouterIds[mc.alias] || picked;
314
315
  }
@@ -324,9 +325,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
324
325
  if (!prov) { prov = provs[0]; }
325
326
  }
326
327
  var route = mc.routes[prov] || null;
327
- // Only canonicalize auto-picks; an explicit "via OpenRouter" pill click
328
- // is a deliberate choice and is returned unchanged.
329
- if (route && !explicitRouteChoices[mc.alias]) { route = toBareIfDirect(route); }
328
+ // issue 214: the SAFE storable form is decided server-side (quick-picks.js
329
+ // canonicalRoutesFor) and shipped with the pick. The page must not re-derive
330
+ // it: its old hand-copy of stripGatewayPrefix dropped both of that
331
+ // primitive's guards. An explicit "via OpenRouter" pill stays unchanged.
332
+ if (route && !explicitRouteChoices[mc.alias]) {
333
+ route = (mc.canonicalRoutes && mc.canonicalRoutes[prov]) || route;
334
+ }
330
335
  return route;
331
336
  }
332
337
 
@@ -485,7 +490,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
485
490
  // undefined) when the key is absent makes "delete of an absent alias"
486
491
  // compare equal to "still absent" without changing any other case --
487
492
  // every other savedAliases value here is a non-empty string.
488
- var changedWrites = {};
493
+ // T3: keyed by user alias names -- null-prototype (see aliasEdits above)
494
+ var changedWrites = Object.create(null);
489
495
  Object.keys(aliasWritesPreview).forEach(function(alias) {
490
496
  var oldVal = Object.prototype.hasOwnProperty.call(savedAliases, alias) ? savedAliases[alias] : null;
491
497
  if (aliasWritesPreview[alias] !== oldVal) {
@@ -570,7 +576,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
570
576
  // layer responsible for not SHOWING those value-identical entries as
571
577
  // changes (see its own N1 comment).
572
578
  function collectAliasWrites(selectedAlias, isCustomDefault) {
573
- var aliasWrites = {};
579
+ // T3: keyed by user alias names -- null-prototype (see aliasEdits above)
580
+ var aliasWrites = Object.create(null);
574
581
  Object.keys(aliasEdits).forEach(function(k) {
575
582
  aliasWrites[k] = aliasEdits[k];
576
583
  });
@@ -5,66 +5,105 @@
5
5
  // Self-contained: it reads only its four arguments and owns its own isReviewing.
6
6
  (function () {
7
7
  /**
8
- * D6 (v4.6.2 PR4 Task 2, "dead-seat rows"): announced-dead seats, unioned
9
- * from the run's own `degrades[]` (dead-leg/dead-wave channels the live
10
- * announcement; shapes verified against src/council/run-retry-notes.js and
11
- * the skipped-path notes at src/council/run-stages.js:155-174) and the
12
- * verdict's derived `seatLoss` (verdict.js summarizeSeatLoss/deriveSeatLoss
13
- * the critic-loss backstop, kept for verdicts written before `degrades[]`
14
- * existed, v4.5.2 precedent). De-duped by seat model: `degrades` is scanned
15
- * FIRST, so a real record's retry marker always beats the backstop's
16
- * no-data guess for the same seat.
8
+ * The kind/channel admission rule, v4.9 W9 (SI-02). ⚠️ MIRROR this rule is spelled a
9
+ * second time at `workspace-seats.js :: retriedSeats`, and the two MUST move together
10
+ * (two spellings of one rule is PR5a council finding B1; the mirror is declared in that
11
+ * function's docblock). A renderer module cannot `require` src/ and this file cannot
12
+ * `require` its sibling, so the mirror is enforced behaviourally instead: the drift pin
13
+ * in `tests/workspace/workspace-seats.test.js` drives BOTH consumers with one fixture set.
17
14
  *
18
- * `retried` reads `data.retryWaveId` / `data.firstFailure`: every
19
- * still-dead-after-retry note (run-retry-notes.js's four builders) carries
20
- * `retryWaveId`; the two run-stages.js skipped-path notes (fired when the
21
- * once-only retry pass never even attempted the seat an unmappable lens
22
- * loss, an out-of-range index, or a zero-model unit) carry neither, so they
23
- * correctly fall back to the plain phrasing.
15
+ * ⚠️ A kind-LESS record IS a degrade (W9 fix round, council C4). Both renderers read
16
+ * `run.json`/`verdict.json` off DISK — `workspace-seats.js :: renderSeatsPanel` even falls back
17
+ * to `verdict.degrades` — i.e. exactly the documents `report.js`'s LEGACYDROP note is about:
18
+ * they can PREDATE kinds, and `utils/degrade.js :: formatDegrade` still serves such a record as
19
+ * 'Notice'. A positive `kind === 'degrade'` silently dropped every one of them.
24
20
  *
25
- * D6 filter (zero usable legs ONLY, "no ghost when a live row already
26
- * exists"): a candidate already present in `liveSeats` is dropped. Two
27
- * different things can put it there, and both are reasons to suppress:
28
- * an SL-2 retry actually healed it (a real recovered review), OR
29
- * (owner-ruled, v4.7 CA-4 dead-seat convergence) the seat never recovered
30
- * at all but the row-per-launch machinery still gives its dead leg an
31
- * honest primary ERROR row (every billed leg gets a row now, including
32
- * failures — run-stages.js/run-assemble.js) — so "it has a cost row" no
33
- * longer implies "it healed". Either way that live row IS the seat's
34
- * record; rendering a second "did not review" ghost row beside it would
35
- * be a duplicate, not new information, so suppressing it here is the
36
- * ACCEPTED terminal-path behavior: exactly one row per seat, whatever its
37
- * status. This is the one thing standing between a recovered (or
38
- * honestly-erred) seat and a duplicate/ghost row — same failure family as
39
- * the F37 debate-role collision and the RN-11 keyed-row lessons just above
40
- * (seatsFromRunStats, seatCells): an identity that is not carefully
41
- * matched silently duplicates or overwrites instead of failing loud.
21
+ * `dead-leg`/`dead-wave` are admitted unconditionally the skipped-LEG note (`run-stages.js`,
22
+ * fired when the once-only retry never attempted the seat) carries no retry-family field and
23
+ * must still render, with the plain phrasing.
42
24
  *
43
- * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
44
- * regardless of what the LIVE row's role was so a model that died as
45
- * critic but whose chair-fallback walk happened to land on that same alias
46
- * (and succeeded, producing a live `role: 'chair'` cost row) silently
47
- * erased the dead-critic row it was never a replacement for (spec §5, the
48
- * PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
49
- * with `runMeta.critic` mirroring `deriveSeatLoss`, verdict.js:72 or
50
- * `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
51
- * suppress at all; a `'critic'` candidate is cleared only by a live
52
- * CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
53
- * row landing on the same model. Hidden dependency: the recovered-critic
54
- * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
55
- * critic branch (src/council/run-stages.js), which only fires when lenses
56
- * are absent — safe today only because --critic and --lenses are mutually
57
- * exclusive (src/cli-handlers-council-run.js's `critic && lenses` check); if
58
- * that exclusion ever loosens, a healed critic on a lens run would carry
59
- * role 'seat' and this suppression would render a ghost dead row for it.
25
+ * `seat-unbound` is admitted GATED, never raw: it is a SHARED channel with SEVEN emit sites
26
+ * (re-counted 2026-08-26 FOUR arms of `run-retry-notes.js`, one in each of `stage1-bind.js`,
27
+ * `run-debate-revote.js` and `run-stage2.js`) that do NOT mean the same thing. `orphanLegNote`
28
+ * and `reVoteUnboundNote` mean "a leg LANDED but names no seat/judge" — a review that was paid
29
+ * for and rendered, not a lost seat; `run-stage2.js`'s judge-side note names a seat that DID
30
+ * review and merely failed to judge. None of the three carries `retryWaveId` or `firstFailure`
31
+ * (measured), so the retry-family test excludes all three and the BACKLOG's proposed
32
+ * `data.legId` discriminator is subsumed rather than duplicated.
60
33
  *
61
- * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
62
- * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
63
- * (string[] of aliases, verdict.js deriveSeatLoss) `degrades[]` never
64
- * existed on either doc for these runs. Consumed after the critic backstop,
65
- * candidates get `role: null` (deadBenchSeats carries no critic/bench
66
- * distinction beyond what `criticRequested` already covers above) and flow
67
- * through the same dedup and role-aware suppression as every other candidate.
34
+ * ⚠️ The seat conjunct is HONESTLY belt-and-braces HERE and load-bearing in the third twin.
35
+ * Measured: dropping it from this function reds nothing, because `add()` already refuses a
36
+ * candidate with no alias; dropping it from `verdict-seat-loss.js :: deriveSeatLoss` puts an
37
+ * `undefined` into `deadBenchSeats`, which every reader of that list treats as a seat. It is
38
+ * kept in all three so one rule has one spelling — pinned on the verdict side.
39
+ *
40
+ * ⚠️ R-W9a is CLOSED at the PRODUCER (W9 fix round, council A1/C1): `run-retry-notes.js ::
41
+ * skippedWaveNote` emits the `firstFailure` fact its record already carried, so this gate
42
+ * admits that real loss UNCHANGED and the three controls above still fail it.
43
+ */
44
+ function isSeatLoss(d) {
45
+ if (!d || (d.kind !== undefined && d.kind !== 'degrade')) { return false; }
46
+ if (d.channel === 'dead-leg' || d.channel === 'dead-wave') { return true; }
47
+ if (d.channel !== 'seat-unbound') { return false; }
48
+ var data = d.data || {};
49
+ return !!((data.retryWaveId || data.firstFailure) && (data.seatId || data.seat));
50
+ }
51
+
52
+ /**
53
+ * D6 (v4.6.2 PR4 Task 2, "dead-seat rows"): announced-dead seats, unioned from the run's own
54
+ * `degrades[]` (the loss channels `isSeatLoss` above admits — the live announcement; shapes
55
+ * verified against src/council/run-retry-notes.js, which since the W9 fix round owns the
56
+ * skipped-path WAVE note too, plus the `retry.skippedDeadLegs` loop still in run-stages.js —
57
+ * anchored BY SYMBOL at v4.9 W9, the old `:155-174` having rotted onto the materialize/repair
58
+ * block) and the verdict's derived `seatLoss` (verdict-seat-loss.js's summarizeSeatLoss /
59
+ * deriveSeatLoss — the critic-loss backstop, kept for verdicts written before `degrades[]`
60
+ * existed, v4.5.2 precedent). De-duped by seat model: `degrades` is scanned FIRST, so a real
61
+ * record's retry marker always beats the backstop's no-data guess for the same seat.
62
+ *
63
+ * `retried` reads `data.retryWaveId` ALONE — narrowed in the W9 fix round, named mutant
64
+ * SKIPRETRIED. That field exists if and only if a retry wave was actually LAUNCHED for the
65
+ * seat: every still-dead-after-retry builder emits it, and both skipped-path notes (the retry
66
+ * never attempted the seat — an unmappable lens loss, an out-of-range index, a zero-model
67
+ * unit, or `overBudget()`) emit none, so they keep the plain phrasing. ⚠️ `firstFailure` is
68
+ * NOT a retry marker: `skippedWaveNote` (the sole `firstFailure`-without-`retryWaveId`
69
+ * producer) describes a seat never retried; every RETRIED seat's builder emits the former.
70
+ *
71
+ * D6 filter (zero usable legs ONLY, "no ghost when a live row already exists"): a candidate
72
+ * already present in `liveSeats` is dropped. Two different things can put it there and both
73
+ * are reasons to suppress: an SL-2 retry actually healed it (a real recovered review), OR
74
+ * (owner-ruled, v4.7 CA-4 dead-seat convergence) the seat never recovered at all but the
75
+ * row-per-launch machinery still gives its dead leg an honest primary ERROR row (every billed
76
+ * leg gets a row now, including failures — run-stages.js/run-assemble.js), so "it has a cost
77
+ * row" no longer implies "it healed". Either way that live row IS the seat's record, and a
78
+ * second "did not review" ghost beside it is a duplicate, not new information — so suppressing
79
+ * here is the ACCEPTED terminal-path behavior: exactly one row per seat, whatever its status.
80
+ * It is the one thing standing between a recovered (or honestly-erred) seat and a duplicate
81
+ * row — the F37 debate-role and RN-11 keyed-row family (seatsFromRunStats, seatCells): an
82
+ * identity that is not carefully matched silently duplicates or overwrites instead of failing.
83
+ *
84
+ * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress regardless of what
85
+ * the LIVE row's role was — so a model that died as critic but whose chair-fallback walk
86
+ * happened to land on that same alias (and succeeded, producing a live `role: 'chair'` cost
87
+ * row) silently erased the dead-critic row it was never a replacement for (spec §5, the PR 102
88
+ * rider). Candidates now carry a `role` — `'critic'` or `null`, decided by `roleOf` below,
89
+ * which since v4.9 W9 (R4) keys on SEAT identity whenever the record and the run can both
90
+ * spell it and falls back to alias equality otherwise (so it no longer mirrors
91
+ * verdict-seat-loss.js :: deriveSeatLoss, which stays alias-keyed on purpose) — and only
92
+ * REVIEWING-role live legs (`seat`/`critic`/`lens:*`) suppress at all; a `'critic'` candidate
93
+ * is cleared only by a live CRITIC-role leg for that seat (alias where no seat id exists),
94
+ * never by a chair/judge/rebuttal/revote row landing on the same model. Hidden dependency: the
95
+ * recovered-critic suppression below (`byRole[key + '|critic']`) relies on `roleFor`'s critic
96
+ * branch (src/council/run-stages.js), which only fires when lenses are absent — safe today
97
+ * only because --critic and --lenses are mutually exclusive (src/cli-handlers-council-run.js's
98
+ * `critic && lenses` check); if that exclusion ever loosens, a healed critic on a lens run
99
+ * would carry role 'seat' and this suppression would render a ghost dead row for it.
100
+ *
101
+ * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2) carry the BENCH
102
+ * half of a seat loss only in `seatLoss.deadBenchSeats` (string[] of aliases, deriveSeatLoss)
103
+ * — `degrades[]` never existed on either doc for these runs. Consumed after the critic
104
+ * backstop, candidates get `role: null` (deadBenchSeats carries no critic/bench distinction
105
+ * beyond what `criticRequested` already covers above) and flow through the same dedup and
106
+ * role-aware suppression as every other candidate.
68
107
  *
69
108
  * ⚠️ v4.8 PR5c corrected HOW that dedup works, and the old description here was
70
109
  * wrong once seats were keyed. Candidates arrive in FOUR identity flavours, and the
@@ -75,7 +114,9 @@
75
114
  * it on the alias is what collapsed two dead twins into one row.
76
115
  * 'legacy' a pre-PR5c record with no seat id anywhere -> dedup on the alias,
77
116
  * which still collapses twins. Disclosed residual R2.
78
- * 'derivative' seatLoss-sourced. verdict.js:86 builds deadBenchSeats FROM the same
117
+ * 'derivative' seatLoss-sourced. `verdict-seat-loss.js :: deriveSeatLoss` (by SYMBOL — the
118
+ * old `verdict.js:86` rotted in v4.9 W9, and the function has since left
119
+ * verdict.js entirely) builds deadBenchSeats FROM the same
79
120
  * dead legs that emit degrades[], so it is always a duplicate and is
80
121
  * the ONLY flavour that may be absorbed by an alias already covered.
81
122
  * Absorbing a real degrade instead would erase an unidentified twin.
@@ -86,8 +127,9 @@
86
127
  * `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
87
128
  * @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
88
129
  * (or any seat list keyed the same way — the live seat map)
89
- * @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
90
- * when no critic was requested) degrade records carry no role field, so
130
+ * @param {?{critic: ?string, criticSeat: ?string}} runMeta run.critic (alias) and
131
+ * run.criticSeat (the seat id `seats.js :: preflightSeats` resolved for it), each
132
+ * null/absent when no critic was requested — degrade records carry no role field, so
91
133
  * this is the ONLY way a degrade-sourced candidate is identified as critic
92
134
  * @returns {Array<{model: string, seat: ?string, statusText: string, role: ?string}>}
93
135
  * `model` is the ALIAS (what the row displays, what labelOf keys on); `seat` is
@@ -95,11 +137,28 @@
95
137
  */
96
138
  function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
97
139
  var critic = runMeta && runMeta.critic ? runMeta.critic : null;
98
- // ⚠️ Object.create(null) throughout this family (also workspace-render.js's
99
- // `existing`/`seen` and workspace-app.js's `labelByModel`): a model literally
100
- // named `toString` is truthy off a bare object, so it was dropped here and
101
- // worse crashed workspace-render.js:212 reading `.children` off an inherited
102
- // function, killing the seats repaint and every tick after it.
140
+ var criticSeat = runMeta && runMeta.criticSeat ? runMeta.criticSeat : null;
141
+ // R4 (v4.9 W9). The old rule was `alias === critic`, which on a bench where one alias holds
142
+ // a critic seat AND a bench seat tagged the WRONG candidate 'critic' and critic candidates
143
+ // suppress through `byRole`, a map PR5c's seat-keying never reached, so a dead bench twin
144
+ // beside a live critic twin rendered NOTHING. Seat identity decides whenever BOTH sides can
145
+ // spell it; a record with no seat id, or a run with no resolved critic seat (pre-`criticSeat`
146
+ // run.json), keeps the legacy alias rule rather than guessing.
147
+ // ⚠️ W9 fix round (council C3): a truthy key is NOT necessarily seat-space. On the INEXACT
148
+ // twin branch `run-retry-group.js :: recordFailure` keys `firstFailure.seatId` by the ALIAS
149
+ // (residual R3), so the critic's OWN record can arrive keyed 'gpt' against a criticSeat of
150
+ // 'gpt#1' and came back role null — while `verdict-seat-loss.js :: deriveSeatLoss`, comparing
151
+ // `data.seat` to the alias, called that same record a critic loss. The alias arm of the
152
+ // disjunct IS that legacy rule, so the two surfaces agree; it cannot re-open R4, since a real
153
+ // bench-twin id ('gpt#2') equals neither operand.
154
+ function roleOf(key, alias) {
155
+ if (key && criticSeat) { return (key === criticSeat || key === critic) ? 'critic' : null; }
156
+ return critic && alias === critic ? 'critic' : null;
157
+ }
158
+ // ⚠️ Object.create(null) throughout this family (also workspace-render.js's `existing`/`seen`
159
+ // and workspace-app.js's `labelByModel`): a model literally named `toString` is truthy off a
160
+ // bare object, so it was dropped here and — worse — crashed workspace-render.js:212 reading
161
+ // `.children` off an inherited function, killing the seats repaint and every tick after it.
103
162
  var seen = Object.create(null);
104
163
  var covered = Object.create(null);
105
164
  var seenUnid = Object.create(null);
@@ -108,12 +167,12 @@
108
167
  // docblock above, which is what decides the key and the absorb rule.
109
168
  function add(key, alias, retried, role, src, slotOf) {
110
169
  if (!alias) { return; }
111
- // ⛔ An 'unid' candidate was originally given NO dedup at all, on the grounds that it is
112
- // one wave slot and distinct by construction. That holds WITHIN a record's seats[]; it
113
- // does NOT hold across records, and the same dead-wave record appearing twice rendered
114
- // four rows for two seats. Deduped on (waveId, slot) instead — its own namespace, never
115
- // `seen`, so an invented key can never collide with a real alias. (The seat literally
116
- // named `toString` is why this family refuses keys that share the alias keyspace.)
170
+ // ⛔ An 'unid' candidate was originally given NO dedup at all, on the grounds that it is one
171
+ // wave slot and distinct by construction. That holds WITHIN a record's seats[]; it does NOT
172
+ // hold across records, and the same dead-wave record appearing twice rendered four rows for
173
+ // two seats. Deduped on (waveId, slot) instead — its own namespace, never `seen`, so an
174
+ // invented key can never collide with a real alias. (The seat literally named `toString` is
175
+ // why this family refuses keys that share the alias keyspace.)
117
176
  if (src === 'unid') {
118
177
  var uk = ((slotOf && slotOf.waveId) || '') + '#' + (slotOf ? slotOf.i : 0);
119
178
  if (seenUnid[uk]) { return; }
@@ -140,23 +199,23 @@
140
199
  order.push(row);
141
200
  }
142
201
  (degrades || []).forEach(function (d) {
143
- if (!d || d.kind !== 'degrade') { return; }
144
- if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
202
+ if (!isSeatLoss(d)) { return; }
145
203
  var data = d.data || {};
146
- var retried = !!(data.retryWaveId || data.firstFailure);
147
- // Critic identification mirrors deriveSeatLoss (verdict.js): alias
148
- // equality with run.critic degrade records carry no role field.
149
- if (d.channel === 'dead-leg') {
204
+ var retried = !!data.retryWaveId; // NOT firstFailure — docblock above, mutant SKIPRETRIED
205
+ // ONE shape, TWO channels: `dead-leg` and every admitted `seat-unbound` arm name a
206
+ // single seat through `data.seat` (alias) plus an optional seat id — the partial arm's
207
+ // `data.seatId` (v4.9 W9 P1) or the two missing-leg arms' `firstFailure.seatId`. Only
208
+ // `dead-wave` is a LIST, which is why the branch tests for it rather than for dead-leg.
209
+ if (d.channel !== 'dead-wave') {
150
210
  var lk = (data.firstFailure && data.firstFailure.seatId) || data.seatId || null;
151
- add(lk, data.seat, retried, critic && data.seat === critic ? 'critic' : null,
152
- lk ? 'keyed' : 'legacy');
211
+ add(lk, data.seat, retried, roleOf(lk, data.seat), lk ? 'keyed' : 'legacy');
153
212
  } else {
154
213
  // `seats[]` is index-parallel with `models[]` (PR5c Task 1). Its ABSENCE means a
155
214
  // legacy record; a null ELEMENT means an unidentified seat. Different statements.
156
215
  var hasSeats = Array.isArray(data.seats);
157
216
  (data.models || []).forEach(function (m, i) {
158
217
  var wk = hasSeats ? data.seats[i] : null;
159
- add(wk, m, retried, critic && m === critic ? 'critic' : null,
218
+ add(wk, m, retried, roleOf(wk, m),
160
219
  wk ? 'keyed' : (hasSeats ? 'unid' : 'legacy'), { waveId: data.waveId, i: i });
161
220
  });
162
221
  }
@@ -165,18 +224,15 @@
165
224
  add(null, seatLoss.criticRequested, false, 'critic', 'derivative');
166
225
  }
167
226
  if (seatLoss) {
168
- // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
169
- // only here. Alias strings; deriveSeatLoss does not dedup — `seen`
170
- // absorbs repeats and degrade-sourced duplicates.
171
- (seatLoss.deadBenchSeats || []).forEach(function (m) {
172
- add(null, m, false, null, 'derivative');
173
- });
227
+ // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives only here. Alias
228
+ // strings; deriveSeatLoss does not dedup — `seen` absorbs repeats and degrade-sourced
229
+ // duplicates.
230
+ (seatLoss.deadBenchSeats || []).forEach(function (m) { add(null, m, false, null, 'derivative'); });
174
231
  }
175
- // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
176
- // a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
177
- // a dead-critic candidate is cleared only by a live CRITIC leg. A null
178
- // role is NOT reviewing: counting it would suppress silently, the exact
179
- // class the announcement invariant forbids.
232
+ // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress — a chair/judge/
233
+ // rebuttal/revote row must not hide a dead reviewer, and a dead-critic candidate is cleared
234
+ // only by a live CRITIC leg. A null role is NOT reviewing: counting it would suppress
235
+ // silently, the exact class the announcement invariant forbids.
180
236
  // Role 'claude' is deliberately absent: it is emitted only by claudeRunStatsRow
181
237
  // (src/council/run-assemble.js:129-132) for a seat that never launches a leg, and
182
238
  // preflightClaudeReview (run-assemble.js:86-102) rejects 'claude' as chair/critic/
@@ -188,6 +244,7 @@
188
244
  }
189
245
  var reviewing = Object.create(null);
190
246
  var byRole = Object.create(null);
247
+ var byRoleUnseated = Object.create(null); // alias|role, live legs that named NO seat id
191
248
  // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
192
249
  // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
193
250
  // executable id, not the alias a degrade record names; `modelInput` carries the alias.
@@ -208,9 +265,24 @@
208
265
  // producers cannot disagree. A bare insert would write a STRING key for every such seat.
209
266
  if (s.seat) { reviewing[s.seat] = true; }
210
267
  byRole[alias + '|' + s.role] = true;
268
+ // R4 (v4.9 W9): the seat arm, exactly mirroring `reviewing` two lines up. Without it a
269
+ // critic candidate carrying a seat id has no seat-keyed entry to match and the lookup
270
+ // below would fall through to a ghost row on every twin bench.
271
+ // ⚠️ The ELSE arm is the W9 fix round's (council C2): a live leg that named NO seat id can
272
+ // only write the alias key, so a seat-KEYED candidate had nothing to match and the read
273
+ // below rendered a ghost 'critic did not review' row the pre-W9 alias read suppressed.
274
+ // Kept OUT of `byRole` on purpose — an alias entry written by a SEATED leg must not clear
275
+ // a different seat's candidate, which is the erasure R4 exists to have fixed.
276
+ if (s.seat) { byRole[s.seat + '|' + s.role] = true; } else { byRoleUnseated[alias + '|' + s.role] = true; }
211
277
  });
212
278
  return order.filter(function (s) {
213
- if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
279
+ // Seat where the candidate has one, alias otherwise — the same key `reviewing` uses
280
+ // below, so the two suppression maps cannot disagree about what a candidate IS. The
281
+ // second disjunct reads the alias keyspace the WRITE also lands in (C2, above): it is
282
+ // consulted only for live legs that named no seat, so it cannot re-open R4's erasure.
283
+ if (s.role === 'critic') {
284
+ return !(byRole[(s.seat || s.model) + '|critic'] || byRoleUnseated[s.model + '|critic']);
285
+ }
214
286
  // An UNNAMED dead seat is never suppressed by alias evidence. The producer emitted
215
287
  // `null` rather than the alias precisely because "unidentified" and "the alias" are
216
288
  // different statements; falling back to the alias here would re-assert the equivalence
@@ -60,15 +60,38 @@
60
60
  '. This run directory cannot distinguish those artifacts, so prose below may be misattributed.', '');
61
61
  return;
62
62
  }
63
- // ⚠️ Fix-wave 3 (council-3 C2): seats[] was present but malformed, so the whole run fell
64
- // back to alias space — two seats running one model are indistinguishable in every panel
65
- // below. Fail-safe, but SILENT until now, which the product principle rejects as hard as
66
- // a crash. Ranked AFTER the collision banner (that one says prose may be misattributed,
67
- // strictly worse) and BEFORE run.error, since it qualifies how to read every panel.
63
+ // ⚠️ Fix-wave 3 (council-3 C2): seats[] was present but malformed, so the run's ARTIFACT
64
+ // NAMING fell back to alias space — two seats running one model are indistinguishable in
65
+ // the prose panels. Fail-safe, but SILENT until now, which the product principle rejects
66
+ // as hard as a crash. Ranked AFTER the collision banner (that one says prose may be
67
+ // misattributed, strictly worse) and BEFORE run.error, since it qualifies how to read
68
+ // those panels.
69
+ // ⚠️ This comment used to say "the WHOLE run fell back to alias space … in every panel
70
+ // BELOW", and W9 measured that as FALSE (it was already false when written, not falsified
71
+ // by this change — the same reading the banner string itself shipped). The correction and
72
+ // its evidence are below, where the sentence the user actually reads is built.
73
+ // ⚠️ v4.9 W9 PR5b-1 (ruling V15, disclosure over uniformity): this used to end "cannot be
74
+ // told apart BELOW" — a blanket claim over the whole detail view, and measurably too wide.
75
+ // `seatTableRejected` reaches exactly one surface: derived.seatSpace goes false, so
76
+ // workspace-lazy.js :: roster falls back to `bench` and the reviews/judges/re-vote panels
77
+ // name their artifacts by alias. Everything sourced from tally.json is untouched — the
78
+ // seats panel's rows are live-seats.js :: seatsFromRunStats over run-detail.js ::
79
+ // costPanel's `tally.runStats` projection, keyed on `r.seat || r.model`, and `r.seat` is
80
+ // stamped by run-stats-entry.js :: buildRunStatsEntry from the IN-MEMORY seat at assembly
81
+ // (run-assemble.js :: buildTallyInput), not from the run.json table that failed to parse.
82
+ // A twin bench therefore still gets two seat rows carrying their own numbers.
83
+ // V15's ruling is to DISCLOSE that split rather than force the seats panel down to
84
+ // derived.seatSpace for uniformity, which would throw away data that is truthful.
85
+ // ⚠️ The ROWS are per-seat; the NAMES are not. live-seats.js :: seatCells prints
86
+ // `seat.modelInput || seat.model` — the alias — so the sentence claims separate NUMBERS,
87
+ // never distinguishable names. Wording pinned full-string in workspace-app-boundary.test.js.
68
88
  if (d.derived.seatTableRejected) {
69
89
  R.renderBanner(el, 'This run\'s seat table could not be read (a malformed entry), so ' +
70
- 'artifacts are shown by model alias instead of per seat two seats running the same ' +
71
- 'model cannot be told apart below. Artifacts: ' + d.runDir, 'warn');
90
+ 'the reviews, judges and re-vote panels name their artifacts by model alias instead ' +
91
+ 'of per seat — two seats running the same model cannot be told apart there. The seats ' +
92
+ 'panel is NOT affected: its rows come from tally.json\'s runStats, one per seat, so ' +
93
+ 'both seats\' numbers are still shown separately, sharing the alias as a name. ' +
94
+ 'Artifacts: ' + d.runDir, 'warn');
72
95
  return;
73
96
  }
74
97
  // ⚠️ PRE-FLIGHT (P3), caught live by the CDP e2e (Task 18): gating on `d.run.error` ALONE
@@ -126,9 +126,15 @@
126
126
  container.textContent = '';
127
127
  var head = R.el('div', { className: 'chips' }, []);
128
128
  if (vp.overallVerdict) {
129
- head.appendChild(R.el('span', { className: 'chip complete' }, ['VERDICT: ' + vp.overallVerdict]));
129
+ // v4.9 W8: the chip names the SCALE the phrase belongs to — a task chair's
130
+ // phrase comes from CHAIR_ANSWERS and is never a verdict. Payload-driven:
131
+ // this file cannot require() src/, so run-detail.js :: verdictPanel mints
132
+ // `intent`. `=== 'task'` (not `!== 'review'`) so a pre-v4.9 payload with no
133
+ // intent key reads as review instead of relabelling every legacy run.
134
+ head.appendChild(R.el('span', { className: 'chip complete' },
135
+ [(vp.intent === 'task' ? 'ANSWER: ' : 'VERDICT: ') + vp.overallVerdict]));
130
136
  } else {
131
- head.appendChild(R.el('span', { className: 'chip error' }, ['no chair verdict']));
137
+ head.appendChild(R.el('span', { className: 'chip error' }, [vp.intent === 'task' ? 'no chair answer' : 'no chair verdict']));
132
138
  if (vp.reason) { head.appendChild(R.el('span', { className: 'empty-note' }, [vp.reason])); }
133
139
  }
134
140
  container.appendChild(head);
@@ -145,8 +151,22 @@
145
151
 
146
152
  if (vp.streetCred && vp.streetCred.length) {
147
153
  var rows = vp.streetCred.map(function (s) {
154
+ // v4.9 W9 — SI-22.4 rider (2): the THIRD street-cred renderer, joining
155
+ // report-md.js :: renderMd and report-html.js :: renderHtml (v4.8 R22.4-6), which
156
+ // both label the row `s.seat || s.model`. Until now a twin bench read `gemini#1`/
157
+ // `gemini#2` in the report and `gemini` TWICE here, different numbers under one
158
+ // identical name. `seat` is emit-when-DIFFERENT (street-cred.js :: computeStreetCred),
159
+ // so a unique-alias bench has no seat key and this renders exactly what it did before.
160
+ // ⚠️ BLIND KEEPS THE ALIAS SPACE, both branches. `opts.labelOf` is ALIAS-keyed
161
+ // (workspace-app.js's state.labelByModel, from run.json's labelMap — label -> alias,
162
+ // which the seat work deliberately left alone: anonymize.js :: assignLabels puts seat
163
+ // identity in the separate seatMap), so the lookup key stays `s.model` and the
164
+ // signature is unchanged. The fallback stays `s.model` too: a seat id CONTAINS its
165
+ // alias, so printing one with blind mode on defeats blind mode — the same rule
166
+ // seat-space.js :: isSeatTable and workspace-lazy.js :: roster were both fixed to.
167
+ // Named mutant RIDERALIAS (workspace-matrix.test.js).
148
168
  var label = opts.labelOf(s.model);
149
- var name = opts.isBlind() && label ? label : s.model;
169
+ var name = opts.isBlind() ? (label || s.model) : (s.seat || s.model);
150
170
  var fmt = function (v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(2); };
151
171
  return R.el('tr', {}, [
152
172
  R.el('td', {}, [name]),