amicus 4.7.1 → 4.8.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 (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +988 -1
  3. package/README.md +5 -4
  4. package/docs/CITATIONS.md +122 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +179 -32
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/troubleshooting.md +3 -3
  12. package/docs/usage.md +2 -2
  13. package/electron/ipc-setup.js +18 -2
  14. package/electron/main.js +46 -3
  15. package/electron/setup-ui-aliases.js +2 -2
  16. package/electron/setup-ui-model.js +99 -9
  17. package/electron/setup-ui-styles.js +22 -0
  18. package/electron/setup-ui.js +231 -29
  19. package/electron/workspace-ui/index.html +9 -0
  20. package/electron/workspace-ui/live-dead-seats.js +228 -0
  21. package/electron/workspace-ui/live-model.js +10 -236
  22. package/electron/workspace-ui/live-seats.js +126 -0
  23. package/electron/workspace-ui/workspace-app.js +6 -41
  24. package/electron/workspace-ui/workspace-banners.js +95 -0
  25. package/electron/workspace-ui/workspace-lazy.js +55 -12
  26. package/electron/workspace-ui/workspace-matrix.js +2 -2
  27. package/electron/workspace-ui/workspace-panels.js +42 -10
  28. package/electron/workspace-ui/workspace-render.js +2 -2
  29. package/electron/workspace-ui/workspace-seats.js +101 -17
  30. package/package.json +4 -1
  31. package/schemas/council-run-live.schema.json +1 -0
  32. package/schemas/council-run.schema.json +19 -0
  33. package/schemas/council-tally.schema.json +34 -2
  34. package/schemas/council-verdict.schema.json +15 -0
  35. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  36. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  37. package/skills/second-opinion/SKILL.md +25 -11
  38. package/src/cli-handlers-doctor.js +18 -14
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/parse-stage2.js +1 -1
  47. package/src/council/peer-split.js +196 -0
  48. package/src/council/report-html.js +12 -5
  49. package/src/council/report-md.js +146 -0
  50. package/src/council/report.js +188 -112
  51. package/src/council/run-assemble.js +100 -79
  52. package/src/council/run-chair.js +17 -1
  53. package/src/council/run-debate-revote.js +268 -0
  54. package/src/council/run-debate.js +92 -102
  55. package/src/council/run-finish.js +70 -0
  56. package/src/council/run-launch.js +45 -18
  57. package/src/council/run-retry-group.js +266 -0
  58. package/src/council/run-retry-keys.js +74 -0
  59. package/src/council/run-retry-launch.js +55 -0
  60. package/src/council/run-retry-notes.js +65 -13
  61. package/src/council/run-retry.js +166 -161
  62. package/src/council/run-stage1-launch.js +27 -10
  63. package/src/council/run-stage1-rows.js +220 -0
  64. package/src/council/run-stage1-superseded.js +156 -0
  65. package/src/council/run-stage2.js +65 -5
  66. package/src/council/run-stages.js +72 -69
  67. package/src/council/run-state.js +1 -1
  68. package/src/council/run-stats-entry.js +71 -0
  69. package/src/council/run-verdict-files.js +52 -0
  70. package/src/council/run.js +42 -52
  71. package/src/council/seats.js +262 -0
  72. package/src/council/stage1-bind.js +142 -0
  73. package/src/council/street-cred.js +258 -0
  74. package/src/council/tally.js +100 -49
  75. package/src/council/verdict.js +49 -1
  76. package/src/headless.js +20 -6
  77. package/src/mcp-tools.js +71 -1
  78. package/src/observe/council-legs.js +7 -1
  79. package/src/observe/live-doc.js +3 -3
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-wave-io.js +26 -1
  82. package/src/sidecar/fanout.js +4 -10
  83. package/src/sidecar/leg-ids.js +19 -0
  84. package/src/sidecar/models-probe.js +7 -4
  85. package/src/sidecar/reopen-spend.js +1 -1
  86. package/src/sidecar/setup.js +137 -2
  87. package/src/utils/alias-audit.js +81 -3
  88. package/src/utils/config.js +109 -11
  89. package/src/utils/curated-models.js +17 -2
  90. package/src/utils/degrade.js +5 -0
  91. package/src/utils/doctor-alias-check.js +152 -0
  92. package/src/utils/model-canonicalization.js +64 -0
  93. package/src/utils/model-shortlist.js +100 -0
  94. package/src/utils/no-output-backstop.js +1 -1
  95. package/src/utils/provider-default-picker.js +93 -45
  96. package/src/utils/provider-default-prompt.js +1 -1
  97. package/src/utils/quick-picks.js +2 -2
  98. package/src/utils/remediation-hints.js +37 -0
  99. package/src/utils/session-index-prune.js +297 -0
  100. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  101. package/src/workspace/artifact-guard.js +8 -114
  102. package/src/workspace/artifact-names.js +222 -0
  103. package/src/workspace/fold-format.js +9 -6
  104. package/src/workspace/live-normalize.js +6 -2
  105. package/src/workspace/matrix-model.js +141 -19
  106. package/src/workspace/run-detail.js +30 -4
  107. package/src/workspace/seat-space.js +143 -0
@@ -167,47 +167,12 @@
167
167
  V.startLiveLoop();
168
168
  }
169
169
 
170
- function renderBanners() {
171
- var d = state.detail;
172
- var R = window.AmicusRender;
173
- if (!d.derived.schemaSupported) {
174
- R.renderBanner($('banner'), 'This run was written by a different amicus version (schemaVersion ' +
175
- d.run.schemaVersion + ') artifacts: ' + d.runDir, 'warn');
176
- return;
177
- }
178
- // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): two distinct bench
179
- // entries that sanitize to the same artifact name (src/workspace/artifact-guard.js's
180
- // artifactAllowlist) mean this run directory cannot hold both models' review/judge files
181
- // under distinct names — drillIntoJudge's artifact lookup would otherwise silently
182
- // misattribute one model's prose to the other. This is a run-integrity defect the user
183
- // must see, not a display quirk to smooth over — surfaced ahead of the run.error/reason
184
- // banner below since it calls into question every judges-panel section's attribution.
185
- if (d.derived.artifactCollisions && d.derived.artifactCollisions.length) {
186
- var c = d.derived.artifactCollisions[0];
187
- R.renderBanner($('banner'),
188
- 'Run integrity error: bench entries ' + c.models.join(' and ') + ' both sanitize to "' + c.sanitized +
189
- '" — this run directory cannot distinguish their artifacts, so prose below may be misattributed.', '');
190
- return;
191
- }
192
- // ⚠️ PRE-FLIGHT (P3), caught live by the CDP e2e (Task 18): gating on `d.run.error` ALONE
193
- // never shows this banner for a `status:'partial'` run — `finalize()` only ever sets
194
- // `run.error` on the exit-1 path (src/council/run.js:98-100); the exit-2 "degraded" path
195
- // leaves it null (run-detail.js:83-91). `verdictPanel.reason` (degradedReason()) already
196
- // covers BOTH cases — exit-1's {code, message} and the partial-run stage-failure/skip
197
- // sentence — so check for either signal, not run.error alone.
198
- if (d.run.error || d.derived.verdictPanel.reason) {
199
- // ⚠️ Code review round 2, finding 2: `d.run.error` is a structured {code, message} object
200
- // (never a string) — passing it straight to renderBanner set `textContent = <object>`,
201
- // which coerces to the literal string "[object Object]". `d.derived.verdictPanel.reason`
202
- // is already the correctly-formatted string (run-detail.js's degradedReason(): "CODE:
203
- // message" on the exit-1 path, or a stage-failure/skip sentence otherwise) — reuse it
204
- // rather than re-deriving the same formatting a second time here.
205
- R.renderBanner($('banner'), d.derived.verdictPanel.reason || 'Run reported an error',
206
- d.run.status === 'error' ? '' : 'warn');
207
- return;
208
- }
209
- R.renderBanner($('banner'), null);
210
- }
170
+ // ⚠️ v4.8 PR5a fix-wave 3: the banner ladder moved to workspace-banners.js — this file
171
+ // hit 310/300 when the orphan-collision and rejected-seat-table banners landed, the same
172
+ // gate that produced workspace-lazy.js out of workspace-panels.js (v4.7 PR7). Kept as a
173
+ // named delegate, not deleted: `window.AmicusApp.renderBanners` is a shipped entry point
174
+ // (workspace-verbs.js:216 calls it after an abort) and boundary tests assert it exists.
175
+ function renderBanners() { window.AmicusBanners.render(); }
211
176
 
212
177
  // ---- blind toggle + keyboard ------------------------------------------
213
178
  $('blind-toggle').addEventListener('change', function (e) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Council Workspace — the run-detail banner ladder (v4.4 §9, spec's "graceful when present").
3
+ *
4
+ * v4.8 PR5a fix-wave 3 extraction of renderBanners out of workspace-app.js, which hit
5
+ * 310/300 when the orphan-collision and rejected-seat-table banners landed. Exactly the
6
+ * treatment workspace-panels.js got in v4.7 PR7 (workspace-lazy.js) and v4.6.2 PR4
7
+ * (workspace-seats.js), on the same gate and the same kind of seam.
8
+ *
9
+ * Split line: this file decides WHICH single banner a run shows and what it says.
10
+ * workspace-app.js keeps `state`, the IPC calls and the render orchestration;
11
+ * workspace-render.js keeps renderBanner(), the DOM write itself.
12
+ *
13
+ * ⚠️ ONE banner, first match wins — every branch returns. That is not incidental: the
14
+ * element is a single node (workspace-render.js sets `textContent`), so a second call would
15
+ * overwrite the first. The ORDER is therefore the ranking, most-alarming first, and each
16
+ * branch below states why it sits where it does. Cross-calls resolve `window.Amicus*` at
17
+ * CALL time, never at this file's load time — the house discipline for every renderer script.
18
+ */
19
+ (function () {
20
+ 'use strict';
21
+
22
+ function render() {
23
+ var A = window.AmicusApp;
24
+ var d = A.state.detail;
25
+ var R = window.AmicusRender;
26
+ var el = A.$('banner');
27
+ if (!d.derived.schemaSupported) {
28
+ R.renderBanner(el, 'This run was written by a different amicus version (schemaVersion ' +
29
+ d.run.schemaVersion + ') — artifacts: ' + d.runDir, 'warn');
30
+ return;
31
+ }
32
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): two distinct bench
33
+ // entries that sanitize to the same artifact name (src/workspace/artifact-guard.js's
34
+ // artifactAllowlist) mean this run directory cannot hold both models' review/judge files
35
+ // under distinct names — drillIntoJudge's artifact lookup would otherwise silently
36
+ // misattribute one model's prose to the other. This is a run-integrity defect the user
37
+ // must see, not a display quirk to smooth over — surfaced ahead of the run.error/reason
38
+ // banner below since it calls into question every judges-panel section's attribution.
39
+ // ⚠️ Fix-wave 3 (council-3 A1): TWO DIFFERENT FACTS, two sentences. The orphan case is
40
+ // not a sanitize collision and must not borrow its wording — `a#1` and `a-1` do NOT
41
+ // sanitize to the same name; an orphaned leg wrote a file under its own alias whose stem
42
+ // happens to BE another seat's artifact name. PR5a added the `orphan` flag
43
+ // (artifact-names.js) and shipped no consumer, so every orphan collision rendered a
44
+ // sentence that was simply untrue about the run.
45
+ // ⚠️ And EVERY entry is rendered, not `[0]`. One stem can legitimately produce two
46
+ // entries — MEASURED on a legacy bench: ['vendor/a','vendor?a'] with a 'vendor?a' orphan
47
+ // emits a sanitize entry AND an orphan entry for stem 'vendor-a'. Different causes, same
48
+ // consequence, so both are stated rather than merged: a merge has to drop one cause.
49
+ if (d.derived.artifactCollisions && d.derived.artifactCollisions.length) {
50
+ var clauses = d.derived.artifactCollisions.map(function (c) {
51
+ // `models` on an orphan entry is [owning entity, orphan alias] — both CLAIMANTS of
52
+ // the stem (artifact-names.js states why the orphan half is not projected into
53
+ // entity space). On a sanitize entry it is the two raw bench values.
54
+ return c.orphan
55
+ ? 'the artifact name "' + c.sanitized + '" is claimed by both ' + c.models.join(' and ') +
56
+ ' — an orphaned leg wrote a file under its own alias and run.json cannot say which owns it'
57
+ : 'bench entries ' + c.models.join(' and ') + ' both sanitize to "' + c.sanitized + '"';
58
+ });
59
+ R.renderBanner(el, 'Run integrity error: ' + clauses.join('; ') +
60
+ '. This run directory cannot distinguish those artifacts, so prose below may be misattributed.', '');
61
+ return;
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.
68
+ if (d.derived.seatTableRejected) {
69
+ 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');
72
+ return;
73
+ }
74
+ // ⚠️ PRE-FLIGHT (P3), caught live by the CDP e2e (Task 18): gating on `d.run.error` ALONE
75
+ // never shows this banner for a `status:'partial'` run — `finalize()` only ever sets
76
+ // `run.error` on the exit-1 path (src/council/run.js:98-100); the exit-2 "degraded" path
77
+ // leaves it null (run-detail.js:83-91). `verdictPanel.reason` (degradedReason()) already
78
+ // covers BOTH cases — exit-1's {code, message} and the partial-run stage-failure/skip
79
+ // sentence — so check for either signal, not run.error alone.
80
+ if (d.run.error || d.derived.verdictPanel.reason) {
81
+ // ⚠️ Code review round 2, finding 2: `d.run.error` is a structured {code, message} object
82
+ // (never a string) — passing it straight to renderBanner set `textContent = <object>`,
83
+ // which coerces to the literal string "[object Object]". `d.derived.verdictPanel.reason`
84
+ // is already the correctly-formatted string (run-detail.js's degradedReason(): "CODE:
85
+ // message" on the exit-1 path, or a stage-failure/skip sentence otherwise) — reuse it
86
+ // rather than re-deriving the same formatting a second time here.
87
+ R.renderBanner(el, d.derived.verdictPanel.reason || 'Run reported an error',
88
+ d.run.status === 'error' ? '' : 'warn');
89
+ return;
90
+ }
91
+ R.renderBanner(el, null);
92
+ }
93
+
94
+ window.AmicusBanners = { render: render };
95
+ })();
@@ -132,8 +132,34 @@
132
132
  });
133
133
  lastWiredRunId = A.state.detail.runId;
134
134
  }
135
- var bench = A.state.detail.run.bench || [];
136
- var debated = !!A.state.detail.run.debate;
135
+ // ⚠️ Fix-wave (council A1): CAPTURED here, at wire time, exactly like `bench` and
136
+ // `debated` have always been — never re-read inside roster(). The `files` closures below
137
+ // are invoked LATER (on first panel open, and again on every same-run rewire), by which
138
+ // point `A.state.detail` may be a different payload entirely: an unreadable run, whose
139
+ // detail carries `error` and no `run` at all. Reading `A.state.detail.run.seats` at that
140
+ // moment threw — a crash path the bench-iteration code it replaced never had, because
141
+ // `bench` was already a local. Capturing also stops roster() from returning run B's
142
+ // seats while `debated` still describes run A.
143
+ var detail = A.state.detail;
144
+ var run = detail.run || {};
145
+ var bench = run.bench || [];
146
+ var debated = !!run.debate;
147
+ // ⚠️ Fix-wave (council B1): the seat-space decision is NOT re-derived here. It is
148
+ // isSeatTable(run.seats) — the same predicate artifactAllowlist gates on — computed in
149
+ // src/workspace/run-detail.js and shipped as its answer. `!seats || !seats.length`, the
150
+ // check this replaces, is strictly weaker: it accepts a seats[] with duplicate, empty or
151
+ // non-string ids that isSeatTable rejects, so a malformed table put derived.artifactsByModel
152
+ // in ALIAS space while this roster handed resolveArtifactName SEAT ids. Every lookup
153
+ // missed, resolveArtifactName returned null, present(null) dropped every row, and all
154
+ // three panels rendered empty with no banner and no error. Absent (a pre-fix detail
155
+ // payload) reads as alias space, which is the space such a payload's map is keyed in.
156
+ var seatSpace = !!(detail.derived && detail.derived.seatSpace);
157
+ // ⚠️ Fix-wave 2 (council-2 A2): captured for the same reason `run` is. renderDetail
158
+ // REBUILDS state.labelByModel per run (workspace-app.js:147-148), so a closure that
159
+ // read it live could pair THIS run's keys with the NEXT run's labels — and this map
160
+ // is what blind mode renders, so the failure is a wrong name shown confidently
161
+ // rather than a visibly empty panel.
162
+ var labels = A.state.labelByModel;
137
163
  // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
138
164
  // artifact is NOT translated into a friendly "not written yet" note anywhere in this
139
165
  // read path — it lands in the panel verbatim, absolute host path and all. `run.debate` is
@@ -146,15 +172,34 @@
146
172
  // files. Applies to review-/judge- too (the same latent gap, just plan-mandated rather
147
173
  // than new).
148
174
  var artifacts = A.state.detail.artifacts || {};
149
- function present(name) { return !!(artifacts[name] && artifacts[name].present); }
175
+ function present(name) { return !!(name && artifacts[name] && artifacts[name].present); }
176
+ // ⚠️ v4.8 PR5a T2: iterate SEATS, not the bench. A repeated alias appears twice in
177
+ // `bench`, so these loops used to build two identical rows that resolved to one
178
+ // alias-named file and were then both dropped by present() — the whole issue-137
179
+ // Workspace symptom. run.json carries `seats[]` unconditionally (run.js checkpoints it), and
180
+ // getRunDetail returns the parsed run wholesale, so it is already on state.
181
+ //
182
+ // ⚠️ The label is resolved from the seat's ALIAS while the seat's ID is carried as
183
+ // identity. `labelByModel` is alias-keyed (workspace-app.js, from run.json's labelMap,
184
+ // which PR5a does not touch — R5-2), so keying it with a seat id yields undefined and
185
+ // AmicusRender.display() falls through to `pair.model` — printing `gemini#1` with blind
186
+ // mode ON. A seat id contains its alias, so rendering one defeats blind mode. This is
187
+ // the same split matrix-model.js already makes for the adjudication columns.
188
+ function roster() {
189
+ if (!seatSpace) {
190
+ return bench.map(function (m) { return { key: m, label: labels[m] }; });
191
+ }
192
+ return run.seats.map(function (s) {
193
+ return { key: s.id, label: labels[s.alias] };
194
+ });
195
+ }
150
196
  // ⚠️ v4.4.1 RN-9: these two titles used to hand-roll `A.state.blind && label ? label : m`
151
197
  // inline. Both now go through AmicusRender.display() — the single blind-flip definition the
152
198
  // re-vote title below already used — so the next blind-mode ruling lands in one place instead
153
199
  // of being re-applied by hand in every file that happens to render an identity.
154
200
  loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
155
- return bench.map(function (m) {
156
- var label = A.state.labelByModel[m];
157
- return { name: window.AmicusPanels.resolveArtifactName(m, 'review'), title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
201
+ return roster().map(function (r) {
202
+ return { name: window.AmicusPanels.resolveArtifactName(r.key, 'review'), title: window.AmicusRender.display({ model: r.key, label: r.label }, A.state.blind) };
158
203
  }).filter(function (f) { return present(f.name); });
159
204
  } };
160
205
  loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
@@ -168,9 +213,8 @@
168
213
  .filter(function (f) { return present(f.name); });
169
214
  } };
170
215
  loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
171
- var files = bench.map(function (m) {
172
- var label = A.state.labelByModel[m];
173
- return { name: window.AmicusPanels.resolveArtifactName(m, 'judge'), title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
216
+ var files = roster().map(function (r) {
217
+ return { name: window.AmicusPanels.resolveArtifactName(r.key, 'judge'), title: 'Judge ' + window.AmicusRender.display({ model: r.key, label: r.label }, A.state.blind) };
174
218
  });
175
219
  if (debated) {
176
220
  // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
@@ -187,9 +231,8 @@
187
231
  // for re-votes the exact cross-match bug Task 18 fixed for review-/judge-. Routed
188
232
  // through resolveArtifactName(m, 'revote') like the other three sites; its built-in
189
233
  // legacy fallback keeps older detail payloads (no artifactsByModel map) correct too.
190
- files = files.concat(bench.map(function (m) {
191
- var label = A.state.labelByModel[m];
192
- return { name: window.AmicusPanels.resolveArtifactName(m, 'revote'), title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
234
+ files = files.concat(roster().map(function (r) {
235
+ return { name: window.AmicusPanels.resolveArtifactName(r.key, 'revote'), title: 'Re-vote ' + window.AmicusRender.display({ model: r.key, label: r.label }, A.state.blind) };
193
236
  }));
194
237
  }
195
238
  return files.filter(function (f) { return present(f.name); });
@@ -114,8 +114,8 @@
114
114
  ]));
115
115
  }
116
116
  // ⚠️ DE-ROT (F38): on `--debate` runs the re-vote rationale IS structured — debate.json
117
- // `revotes[] {judge, id, verdict, reason}` (run-debate.js:257-262) so the parenthetical must
118
- // not claim "no structured field". Wording below is corrected to cover both cases.
117
+ // `revotes[] {judge, id, verdict, reason}` (run-debate.js :: runDebate's `revotesJson` push)
118
+ // — so the parenthetical must not claim "no structured field". Wording below covers both.
119
119
  container.appendChild(R.el('p', { className: 'empty-note' }, [
120
120
  'Legend: ✓ agree · ✗ dispute · – neutral · * raiser · click a dispute cell for the judge’s prose (rationale lives in prose; on --debate runs a re-voted cell also carries a structured reason from debate.json).',
121
121
  ]));
@@ -38,12 +38,23 @@
38
38
  // computation only when the map itself is absent: older detail payloads (pre-v4.5 runs,
39
39
  // live-doc consumers not yet updated to build the map) never carry it, and re-deriving via
40
40
  // sanitizeName is exactly what those payloads always did, so it stays correct for them too.
41
+ // ⚠️ v4.8 PR5a T2: the map is now keyed by SEAT ID when the run carries a seat table,
42
+ // and callers pass seat ids to match (RULE OF ONE SPACE — roster, keys and values move
43
+ // together). The legacy re-derivation below is now REACHABLE in a way it never was: an
44
+ // alias-keyed caller used to always hit the map, so the fallback only ever served
45
+ // pre-v4.5 payloads. Under seat keys an alias-keyed caller MISSES — and because a
46
+ // fallback name can be a real file the guard admits (artifact-names.js), re-deriving
47
+ // would hand back another seat's prose instead of the honest empty state. So a miss
48
+ // now returns null and the caller renders "not written yet".
49
+ // Keep the map absent -> legacy path: pre-v4.5 payloads have no map at all and their
50
+ // callers still pass aliases, which is exactly what sanitizeName expects.
41
51
  function resolveArtifactName(model, kind) {
42
52
  var A = window.AmicusApp;
43
- var map = A.state.detail.derived && A.state.detail.derived.artifactsByModel;
44
- var entry = map && map[model];
45
- if (entry && entry[kind]) { return entry[kind]; }
46
- return kind + '-' + sanitizeName(model) + '.md';
53
+ var derived = A.state.detail.derived;
54
+ var map = derived && derived.artifactsByModel;
55
+ if (!map) { return kind + '-' + sanitizeName(model) + '.md'; }
56
+ var entry = map[model];
57
+ return (entry && entry[kind]) ? entry[kind] : null;
47
58
  }
48
59
 
49
60
  // ⚠️ D8 extraction (Task 1): body moved verbatim to workspace-seats.js
@@ -83,11 +94,23 @@
83
94
 
84
95
  // ⚠️ DE-ROT (F38): on a --debate run the FINAL tally.json is rebuilt from the debate's
85
96
  // replaced adjudications, so a matrix `dispute` cell can be a re-vote — gate per
86
- // (judge, findingId), not per run: a judge gets ONE re-vote leg covering only the ids it
87
- // actually re-voted; every other dispute cell still belongs to judge-*.md. debate.json's
88
- // `revotes[]` is keyed on the bench ALIAS (same key revote-*.md and state.labelByModel use),
89
- // so no filename inversion is needed. Returns the settle promise so callers (and tests) can
90
- // await the highlight instead of guessing when it lands.
97
+ // (judge, findingId), not per run: a re-voting seat gets ONE re-vote leg covering only the ids
98
+ // it actually re-voted; every other dispute cell still belongs to judge-*.md. debate.json's
99
+ // `revotes[].judge` is still keyed on the bench ALIAS (src/council/run-debate.js :: runDebate keeps it
100
+ // alias-valued and puts the seat in a sibling `seat` field only when the two differ), which is
101
+ // the same key state.labelByModel uses, so this join needs no inversion.
102
+ // ⚠️ v4.8 PR5a T2 CLOSES the PR3 forecast that stood here. The allowlist is seat-space now
103
+ // (artifact-names.js) and the matrix has been seat-space since PR4c, so `judgePair.model` is
104
+ // a SEAT ID and the name resolves. That is exactly why the re-vote join below had to move in
105
+ // the SAME commit: `revotes[].judge` stays alias-valued by design (run-debate.js :: runDebate), so an
106
+ // alias-vs-seat comparison is permanently false. At HEAD that was harmless — the name missed
107
+ // too, so nothing rendered — but once the name resolves, a false join silently serves the
108
+ // ORIGINAL judge's prose highlighted as the re-vote. `revotes[]` carries a sibling `seat`
109
+ // emit-when-different, so `(r.seat || r.judge)` is ONE term that is correct in both spaces;
110
+ // an `alias AND seat` conjunction would be unsatisfiable on every unique-alias bench, i.e.
111
+ // on every bench that has ever run.
112
+ // Returns the settle promise so callers (and tests) can await the highlight instead of
113
+ // guessing when it lands.
91
114
  function drillIntoJudge(judgePair, findingId) {
92
115
  var A = window.AmicusApp;
93
116
  var panel = A.$('judges-panel');
@@ -96,13 +119,22 @@
96
119
  if (!spec) { return Promise.resolve(); }
97
120
  return window.AmicusLazy.loadPanel('judges-panel', spec.bodyId, spec.files).then(function () {
98
121
  var rv = ((A.state.debate && A.state.debate.revotes) || []).find(function (r) {
99
- return r.judge === judgePair.model && r.id === findingId;
122
+ return (r.seat || r.judge) === judgePair.model && r.id === findingId;
100
123
  });
101
124
  // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this branch used to recompute the name via
102
125
  // bare sanitizeName(judgePair.model), independently of the (already-fixed) judge branch
103
126
  // right below it — for a colliding pair, drilling a re-vote on the SECOND model resolved
104
127
  // to the bare name and cross-matched the FIRST model's genuine revote section. Both arms
105
128
  // of this ternary now go through the same disambiguation-aware helper.
129
+ // ⚠️ NO ALIAS FALLBACK — refused on purpose (council-3 B3, owner-ruled). The join above
130
+ // misses for debate.json written between 2026-08-12 (run.json began carrying seats[])
131
+ // and 2026-08-13 (revotes[] began carrying seat): those rows have only `judge`, an
132
+ // ALIAS, while judgePair.model is a seat id. Adding `|| r.judge === <this seat's alias>`
133
+ // would "fix" it by picking the wrong twin's re-vote roughly half the time — both twins
134
+ // write judge:'gemini' and nothing in that data distinguishes them, so it is RN-1 again,
135
+ // silent. Missing means rv stays undefined and the judge- artifact renders: the right
136
+ // seat, the wrong KIND, which is the fail-safe direction. That vintage never shipped in
137
+ // a release (`git tag --contains` on the first commit is empty).
106
138
  var artifactName = rv
107
139
  ? resolveArtifactName(judgePair.model, 'revote')
108
140
  : resolveArtifactName(judgePair.model, 'judge');
@@ -196,7 +196,7 @@
196
196
  seen[key] = true;
197
197
  var row = existing[key];
198
198
  // ⚠️ DE-ROT (F35): seat.lastActivity is the ISO `leg.lastActivityAt`; format it HERE.
199
- // seatCells lives in live-model.js, which loads first and has no access to relTime.
199
+ // seatCells lives in live-seats.js, which loads first and has no access to relTime.
200
200
  var view = Object.assign({}, seat, {
201
201
  lastActivity: seat.lastActivity ? relTime(seat.lastActivity) : null,
202
202
  });
@@ -217,7 +217,7 @@
217
217
  // RN-11 (v4.5): the keyed update added and removed rows but never MOVED
218
218
  // them, so table order was frozen at first render — wrong the moment a
219
219
  // repair solo or new wave changes the composed doc's leg order mid-run.
220
- // The O(n²) `find()` per seat is deliberate: bench is capped at 26 (anonymize.js:21),
220
+ // The O(n²) `find()` per seat is deliberate: bench is capped at 26 (anonymize.js :: assignLabels),
221
221
  // so this is ~100 rows worst case on a debate run, every 1.5s. `existing` above is
222
222
  // already the map an O(n) rewrite would need — reach for it only if this table ever
223
223
  // renders unbounded rows.
@@ -12,7 +12,7 @@
12
12
  * `getRunDetail` returns them wholesale, unfiltered), so `run.degrades` and
13
13
  * `verdict.seatLoss` are already on `state.detail` today; no data-layer
14
14
  * threading was needed. Only the derivation (window.AmicusLive.deadSeats,
15
- * live-model.js) and this file's painting are new.
15
+ * live-seats.js) and this file's painting are new.
16
16
  *
17
17
  * NOTE (scope, matches the plan's file list): renderSeatsPanel() (below) is reached from
18
18
  * renderDetail() — called both from openRun() (a fresh run open) and from the blind toggle
@@ -42,19 +42,35 @@
42
42
  'use strict';
43
43
 
44
44
  /**
45
- * Aliases of seats whose degrade record says they were retried. PR1F-4 (v4.7 PR7).
45
+ * Keys seat id where a record supplies one, alias otherwise of seats whose degrade record
46
+ * says they were retried. PR1F-4 (v4.7 PR7); re-keyed in v4.8 PR5b, renamed from
47
+ * `retriedAliases` because the keys are no longer uniformly aliases.
46
48
  *
47
- * ⚠️ Mirrors window.AmicusLive.deadSeats' own predicate (live-model.js:227-241) EXACTLY, and
48
- * must keep mirroring it. The kind/channel filter is load-bearing: run.degrades[] also carries
49
+ * ⚠️ THE MIRROR IS RESTORED (v4.8 PR5c). It went partial in PR5b, when this side was
50
+ * seat-keyed and deadSeats still dedup'd on the alias; PR5c seat-keyed the other side too,
51
+ * so both now key on "seat id where the record supplies one, alias otherwise". The
52
+ * kind/channel FILTER still mirrors window.AmicusLive.deadSeats — now at
53
+ * live-dead-seats.js, which PR5c split out of live-seats.js for the 300-line gate — and
54
+ * must keep doing so. Two spellings of one rule is PR5a council finding B1.
55
+ *
56
+ * ⚠️ The two sides are NOT identical, and the difference is deliberate: deadSeats decides
57
+ * whether a seat is RENDERED, so it fails toward showing a row; this decides whether a
58
+ * badge is PAINTED, so an unidentified record falls back to the alias and marks every seat
59
+ * sharing it. Over-badging is visible and self-correcting; a missing badge is silent.
60
+ *
61
+ * The kind/channel filter is load-bearing: run.degrades[] also carries
49
62
  * kind:'heal' / channel:'stage1-retry' records with the SAME retryWaveId/firstFailure fields
50
63
  * for seats that RECOVERED, and a field-only scan would tag a recovered seat "retried once".
51
64
  *
52
- * ⚠️ firstFailure is TRUTHINESS ONLY. It has two shapes run-retry.js:98 emits
53
- * {seat, class:'leg', status, reason}; :86/:90/:93 emit {seat, class:'wave', waveId, reason}
54
- * with NO status key so any read of firstFailure.status is undefined on every wave-origin
55
- * seat.
65
+ * ⚠️ firstFailure is TRUTHINESS ONLY. Two shapes, both built in
66
+ * run-retry-group.js :: groupStage1Losses its deadLegs loop emits {seat, class:'leg',
67
+ * status, reason}; its deadWaves loop emits {seat, class: lossClass(w), waveId, reason}
68
+ * at THREE sites (lens/critic/bench), none carrying a status key, where
69
+ * `const lossClass = w => (w.partial ? 'missing' : 'wave')` — so firstFailure.status is
70
+ * undefined on every wave-origin seat. ⚠️ T-A8 DROPPED five line numbers here: re-opened
71
+ * 2026-08-17 they had all rotted a uniform +31 (T-A3 +15, T-A6 +16), and "now 235" is 266.
56
72
  */
57
- function retriedAliases(degrades) {
73
+ function retriedSeats(degrades) {
58
74
  var out = Object.create(null);
59
75
  (degrades || []).forEach(function (d) {
60
76
  if (!d || d.kind !== 'degrade') { return; }
@@ -62,15 +78,32 @@
62
78
  var data = d.data || {};
63
79
  if (!(data.retryWaveId || data.firstFailure)) { return; }
64
80
  if (d.channel === 'dead-leg') {
65
- if (data.seat) { out[data.seat] = true; }
81
+ // ⚠️ Prefer the SEAT ID when the record names one. TWO mechanisms now supply it, not
82
+ // one: retryLegStillDeadNote and missingLegStillDeadNote via `firstFailure.seatId`
83
+ // (pinned by tests/council/run-retry.test.js:628 on a twin bench), and — since
84
+ // v4.8 PR5c — srcLegStillDeadNote via its own `data.seatId`. Reading only the first
85
+ // meant a srcLeg record keyed by ALIAS and badged the live twin "retried once" while
86
+ // the seat that was actually retried showed nothing.
87
+ // `data.seat` remains the last fallback for pre-PR5c records (residual R6).
88
+ var key = (data.firstFailure && data.firstFailure.seatId) || data.seatId || data.seat;
89
+ if (key) { out[key] = true; }
66
90
  } else {
67
- (data.models || []).forEach(function (m) { if (m) { out[m] = true; } });
91
+ // v4.8 PR5c: dead-wave now carries `seats[]`, index-parallel with `models[]`, whose
92
+ // elements are seat ids or `null` for a slot the producer could not identify. A null
93
+ // slot falls back to the alias and therefore badges every seat sharing it — the loud
94
+ // direction, disclosed. A pre-PR5c record has no `seats` at all and is alias-only.
95
+ var ws = data.seats;
96
+ (data.models || []).forEach(function (m, i) {
97
+ var k = (Array.isArray(ws) ? ws[i] : null) || m;
98
+ if (k) { out[k] = true; }
99
+ });
68
100
  }
69
101
  });
70
102
  return out;
71
103
  }
72
104
 
73
- // Mirrors isReviewing at live-model.js:261-264 a chair/judge/rebuttal/revote row must not
105
+ // Mirrors isReviewing at live-dead-seats.js (moved there by PR5c's size-gate split) —
106
+ // a chair/judge/rebuttal/revote row must not
74
107
  // carry a reviewer's retry marker.
75
108
  function isReviewingRole(role) {
76
109
  return role === 'seat' || role === 'critic' ||
@@ -91,7 +124,7 @@
91
124
  // records for the SAME run, and the persisted run.json copy is authoritative when present.
92
125
  var deg = (d.run && d.run.degrades && d.run.degrades.length) ? d.run.degrades
93
126
  : ((d.verdict && d.verdict.degrades) || []);
94
- var retried = retriedAliases(deg);
127
+ var retried = retriedSeats(deg);
95
128
  // ⚠️ Look rows up by data-key, NEVER by position. renderSeats (workspace-render.js:179-216)
96
129
  // keys every row on String(seat.id || seat.model) and RN-11 made it REORDER rows to match the
97
130
  // composed doc's leg order — so tbody.children[i] is not seats[i]. Build the key exactly the
@@ -105,7 +138,7 @@
105
138
  if (!row || !row.children[8]) { return; }
106
139
  // Column 8 is the table's unlabeled trailing flag cell (index.html:51's final <th></th>).
107
140
  // It carries '⏳ stalled' on the LIVE path; on this terminal path seatsFromRunStats
108
- // hardcodes stalled:false (live-model.js:128), so it is always empty here and free to use.
141
+ // hardcodes stalled:false (live-seats.js, seatsFromRunStats), so it is always empty here and free to use.
109
142
  // If that ever changes, this is the collision site.
110
143
  // Fix wave (whole-branch review, finding 2): this pass must be SYMMETRIC. renderSeats
111
144
  // reuses rows keyed on `model:role` across calls — including across two different
@@ -114,7 +147,47 @@
114
147
  // of the SAME run and leaves a stale 'seat-retried' class on a row that belonged to a
115
148
  // PREVIOUS run's non-retried seat. classList.add/remove (not string concatenation) so a
116
149
  // repeat add never duplicates the token and a seat that is no longer retried gets cleared.
117
- var isRetried = isReviewingRole(s.role) && !!retried[s.modelInput || s.model];
150
+ // ⚠️ v4.8 PR5b: DUAL lookup, and both arms are load-bearing. `s.seat` (PR5b Task 1, null
151
+ // on a unique bench) matches the one emitter arm that names a seat; `s.model` matches the
152
+ // four that carry only an alias — srcLegStillDeadNote and every dead-wave record. Pairing
153
+ // this map's dual key with a seat-id-ONLY lookup badges NOTHING for those four arms: a
154
+ // silent false negative replacing a visible false positive, which is strictly worse.
155
+ // On a twin bench an alias-only record still badges BOTH seats. That is not a defect — the
156
+ // record does not say which seat failed, so no consumer can attribute it. Over-badging is
157
+ // the deliberate direction: visible beats silent.
158
+ //
159
+ // ⚠️ NAMING HAZARD (council A1): `seat` means TWO different things three lines apart.
160
+ // `s.seat` is a SEAT ID (`alias#N`, from src/council/run-stats-entry.js :: buildRunStatsEntry
161
+ // via the cost row). The degrade records keyed into `retried` above use `data.seat`,
162
+ // which is an ALIAS and stays one
163
+ // deliberately (run-retry-notes.js:39-45 — verdict.js:72 compares it against `o.critic`).
164
+ // Reading one as the other is precisely how an earlier revision of this fix paired an
165
+ // alias-keyed map with a seat-id lookup and dropped every badge. When touching either
166
+ // side, say which space you are in.
167
+ //
168
+ // ⚠️ The pre-PR expression was `retried[s.modelInput || s.model]` (council B1). The
169
+ // `modelInput` arm is dropped on purpose: this loop only ever iterates
170
+ // `seatsFromRunStats(...)` output (assigned in workspace-seats.js :: renderSeatsPanel), and
171
+ // that projection emits no `modelInput` at all — live payload seats, which DO carry it,
172
+ // reach `deadSeats` in workspace-seats.js :: appendDeadRows and never reach here. The
173
+ // invariant is pinned by test (12) in workspace-seats.test.js;
174
+ // if that test ever fails, restore the `s.modelInput` arm rather than deleting the test.
175
+ // ⚠️ `s.seat &&` is LOAD-BEARING (council A1). `s.seat` is null on a unique bench, and a
176
+ // bare `retried[s.seat]` coerces null to the STRING key 'null' — so a seat with no seat id
177
+ // would match a degrade record whose alias is literally `null`. Measured: with
178
+ // `m = Object.create(null); m['null'] = true`, `m[null]` is `true`. Contrived, but this
179
+ // module already guards the same class elsewhere — live-dead-seats.js, deadSeats' Object.create(null) note, records a model
180
+ // named `toString` crashing the seats repaint, which is why Object.create(null) is used
181
+ // throughout. The guard costs nothing and closes it.
182
+ //
183
+ // ⚠️ KEYSPACE (council B1): `retried` deliberately mixes seat ids (`alias#N`) and bare
184
+ // aliases as keys, because only one of five emitter arms supplies a seat id. That is a
185
+ // real collision surface and a KNOWN one — src/council/seats.js:236 already records that
186
+ // a literal alias containing '#' collides with a minted #N id, and preflightSeats refuses
187
+ // exactly that shape. This map inherits that guarantee rather than re-deriving it; if
188
+ // preflightSeats ever stops refusing it, this lookup becomes ambiguous.
189
+ var isRetried = isReviewingRole(s.role)
190
+ && !!((s.seat && retried[s.seat]) || retried[s.model]);
118
191
  if (isRetried) {
119
192
  row.classList.add('seat-retried');
120
193
  row.children[8].textContent = '↻ retried once';
@@ -153,14 +226,25 @@
153
226
  // Fix wave 2 (smoke-caught, GUI smoke on real degraded run 12c96b6b): dead seats never
154
227
  // produce a review, so state.labelByModel (built from the run's names derivation — models
155
228
  // that DID review) never carries them; seatCells' own `blindOn && label ? label : alias`
156
- // fallback is LOAD-BEARING for LIVE rows (RN-9/F36, live-model.js) and stays untouched, but
229
+ // fallback is LOAD-BEARING for LIVE rows (RN-9/F36, live-seats.js) and stays untouched, but
157
230
  // for a dead seat that fallback leaks the raw model name under blind — precisely the seat
158
231
  // blind mode most needs to hide. Placeholder ONLY when blind is on AND no label resolved;
159
232
  // a label that DOES resolve (possible in principle) still wins via seatCells' own cell.
160
233
  if (blindOn && !(labelOf && labelOf(seat.model))) { cells[0] = '(masked)'; }
161
234
  cells[6] = '';
235
+ // v4.8 PR5c: key on the SEAT, not the alias — two dead twins are two rows and must
236
+ // not share a dataset.key. ⚠️ Honest scope: unlike the live path, this collision has
237
+ // NO measured symptom today. renderSeats removes leavers per ROW
238
+ // (workspace-render.js:231 tests each child's own key), so colliding rows are both
239
+ // removed rather than one leaking, and dead rows are always appended fresh so the
240
+ // reuse path at :197 — where last-wins froze a live row in PR5b — is never reached.
241
+ // It is fixed because :188 still builds a last-wins `existing` map that any future
242
+ // reuse would hit, and because rows for different seats having one key is a landmine.
243
+ // Plain concatenation is injective here (ONE field, and 'dead:' cannot collide with a
244
+ // live key, which is a JSON array starting '['); the live path needs JSON.stringify
245
+ // only because it joins TWO fields.
162
246
  var row = window.AmicusRender.el('tr',
163
- { className: 'seat-dead', dataset: { key: 'dead:' + seat.model } },
247
+ { className: 'seat-dead', dataset: { key: 'dead:' + (seat.seat || seat.model) } },
164
248
  cells.map(function (c, i) {
165
249
  return window.AmicusRender.el('td',
166
250
  { className: window.AmicusRender.seatCellClass(i) }, [c]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.7.1",
3
+ "version": "4.8.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -63,6 +63,7 @@
63
63
  "models:info": "node bin/amicus.js models",
64
64
  "models:check": "node bin/amicus.js models --check",
65
65
  "models:check:strict": "node bin/amicus.js models --check --strict",
66
+ "check:ci-alias-pins": "node scripts/check-ci-alias-pins.js",
66
67
  "generate-icon": "node scripts/generate-icon.js",
67
68
  "generate-docs": "node scripts/generate-docs.js",
68
69
  "generate-docs:check": "node scripts/generate-docs.js --check",
@@ -71,6 +72,7 @@
71
72
  "graphify:index": "graphify extract . --backend claude-cli",
72
73
  "check:secrets": "node scripts/check-secrets.js --all",
73
74
  "check:sizes": "node scripts/check-file-sizes.js --all",
75
+ "check:citations": "node scripts/check-citations.js --all",
74
76
  "check:tarball": "node scripts/check-tarball-lifecycle.js"
75
77
  },
76
78
  "dependencies": {
@@ -93,6 +95,7 @@
93
95
  "eslint": "^8.0.0",
94
96
  "espree": "^9.6.1",
95
97
  "jest": "^29.0.0",
98
+ "js-yaml": "^4.3.0",
96
99
  "lint-staged": "^16.3.2",
97
100
  "puppeteer": "^24.36.0",
98
101
  "sharp": "^0.35.3",
@@ -39,6 +39,7 @@
39
39
  "model": { "type": ["string", "null"], "description": "Resolved executable id (metadata.model)." },
40
40
  "modelInput": { "type": ["string", "null"], "description": "Council alias (run.json bench/chair/critic/lenses are keyed on this, not `model`); null when the leg's metadata.json has not been patched with it yet (F36)." },
41
41
  "role": { "type": ["string", "null"], "description": "'seat' | 'critic' | 'lens:<slug>' | 'chair', derived via roleFor keyed on modelInput (chair via the owning stage's name instead, F34); null when modelInput is unknown." },
42
+ "seat": { "type": ["string", "null"], "description": "v4.8 R5, optional. This leg's seat id (`alias#N`) when the bench repeats an alias, else null. Written at launch by src/sidecar/fanout-leg.js :: runSingleAttempt from the per-wave roster and read back by src/observe/council-legs.js :: buildLegRow. Present on every leg row, including a truthful null — unlike metadata.json's own `seat` key, which is absent (not null) on a unique-alias bench: `buildLegRow`'s base row literal is unconditional and already emits nulls the same way for `model`/`modelInput`, so this field matches that literal rather than the absent-when-unset convention `usageError` above uses. The leg-row `items` object declares no `additionalProperties: false`, so an additive field was already accepted here — this documents the shape rather than changing what is accepted." },
42
43
  "status": { "type": "string" },
43
44
  "messages": { "type": "number" },
44
45
  "stage": { "type": "string" },
@@ -43,6 +43,25 @@
43
43
  }
44
44
  },
45
45
  "labelMap": { "type": ["object", "null"], "additionalProperties": { "type": "string" } },
46
+ "seats": {
47
+ "description": "v4.8: derived seat table, one entry per bench position. Bench-only — the synthesized 'claude' review is never a seat.",
48
+ "type": ["array", "null"],
49
+ "items": {
50
+ "type": "object",
51
+ "required": ["id", "alias", "role", "position"],
52
+ "properties": {
53
+ "id": { "type": "string" },
54
+ "alias": { "type": "string" },
55
+ "role": { "type": "string" },
56
+ "lens": { "type": ["string", "null"] },
57
+ "position": { "type": "integer" }
58
+ }
59
+ }
60
+ },
61
+ "criticSeat": {
62
+ "description": "v4.8: seat id of the critic, or null.",
63
+ "type": ["string", "null"]
64
+ },
46
65
  "options": { "type": "object" },
47
66
  "pack": { "type": "object" },
48
67
  "tag": { "type": "string" },