amicus 4.6.2 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Council Workspace — lazy prose-panel loading (v4.4 §5.2). v4.7 PR7 extraction of the
3
+ * loading machinery out of workspace-panels.js, which was at 294/300 with the T19 stale-paint
4
+ * fixes still to land; the same treatment workspace-seats.js got in v4.6.2 PR4 (D8).
5
+ *
6
+ * Split line: this file owns WHEN and WHETHER an artifact read is issued and which reply is
7
+ * allowed to paint (the `loading` promise cache, the run/issue staleness fences).
8
+ * workspace-panels.js keeps NAME RESOLUTION (sanitizeName / resolveArtifactName — the RN-1
9
+ * disambiguation pair, pinned by tests/electron/workspace-ui-static.test.js) and the panel
10
+ * adapters. Cross-calls resolve `window.Amicus*` at CALL time, never at this file's load time
11
+ * — the house discipline for every renderer script.
12
+ *
13
+ * Loads AFTER workspace-seats.js and BEFORE workspace-panels.js (index.html), whose
14
+ * wireLazyPanels/proseLoader are thin delegates into this namespace.
15
+ */
16
+ (function () {
17
+ 'use strict';
18
+
19
+ // ---- lazy prose panels (spec §5.2: load on first open; NOT cached across a same-run rewire
20
+ // — a blind flip or the live loop's terminal refresh both drop it, T19-m1) -----------------
21
+ // ⚠️ DE-ROT (F09): a NEW toggle listener stacking on every renderDetail() call is the bug this
22
+ // shape exists to avoid — see wireLazyPanels()/proseLoader() below. Register the three
23
+ // listeners ONCE at boot (workspace-app.js's boot block calls proseLoader per panel id) and
24
+ // dispatch through this module-level `loaders` map, which renderDetail (via wireLazyPanels)
25
+ // overwrites per run.
26
+ //
27
+ // ⚠️ PRE-FLIGHT (P4): the load is AWAITABLE — drillIntoJudge needs to know when it has
28
+ // settled (the old code guessed with setTimeout(render, 300), which could fire before an
29
+ // unbounded N-artifact IPC round trip finished and silently render nothing). loadPanel()
30
+ // is idempotent per panel id and returns its in-flight promise; the promise cache
31
+ // (`loading`) and the per-run spec (`loaders`) are both keyed by panel id. `loading` is
32
+ // dropped by wireLazyPanels() on BOTH a run CHANGE and a same-run rewire (T19-m1, Task 3) —
33
+ // the run-change drop is what stops F09's stale-run artifact requests; the same-run drop is
34
+ // what T19-m1's `issue` token (below) fences against repainting a superseded same-run wave.
35
+ var loaders = {}; // panelId -> {bodyId, files} (rewritten per run by wireLazyPanels)
36
+ var loading = {}; // panelId -> Promise (dropped on every run CHANGE or same-run rewire)
37
+ // Task 19 (RN-5): the run wireLazyPanels() last reset panels/loading for — gates the reset
38
+ // below to run CHANGES only. A same-run call (renderDetail() runs this on every blind toggle
39
+ // too, and on the live loop's terminal refresh) instead drops the cached load for EVERY
40
+ // tracked panel — open or closed (T19-m1, Task 3) — and reissues a fetch for whichever panel
41
+ // is currently open (Fix 1).
42
+ var lastWiredRunId = null;
43
+ // ⚠️ T19-m1 (v4.7 PR7): the only staleness fence used to be the runId captured at issue time,
44
+ // which cannot distinguish two requests issued for the SAME run — a blind flip, a manifest that
45
+ // grew, or any same-run rewire issues a second load while the first is still in flight, and
46
+ // whichever settles LAST won the paint. Monotonic per-panel issue number: the completion handler
47
+ // paints only if it is still the newest issue. Keys are the three fixed panel-id literals (never
48
+ // a model name), so a bare object is safe here — unlike the model-keyed maps in live-model.js.
49
+ var issue = {}; // panelId -> monotonically increasing issue number
50
+
51
+ function loadPanel(panelId, bodyId, files) {
52
+ var A = window.AmicusApp;
53
+ if (loading[panelId]) { return loading[panelId]; }
54
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): this is the third
55
+ // instance of the F09 class of bug (a stale async response overwriting shared DOM after
56
+ // the user has navigated away) — already fixed once for the toggle-listener stack (F09
57
+ // itself) and once for the fire-and-forget debate.json fetch in workspace-app.js (guards
58
+ // with `if (state.runId !== runId) return;`). wireLazyPanels() clearing `loading[panelId]`
59
+ // on every run switch permits a NEW request to be issued, but never fenced the PRIOR
60
+ // request's eventual resolution — open reviews-panel on run A, switch to run B (which
61
+ // issues its own request), and A's response — however late — used to overwrite whatever
62
+ // B had just rendered. Capture the runId this request was issued for, and guard as the
63
+ // FIRST statement of the completion handler, exactly like the debate.json fix.
64
+ var runId = A.state.runId;
65
+ // ⚠️ T19-m1 (v4.7 PR7): captured the same way as `runId` above — read once, at issue time —
66
+ // so the completion guard below compares against the value in force when THIS request was
67
+ // issued, not whatever `issue[panelId]` has become by the time it resolves.
68
+ var token = (issue[panelId] = (issue[panelId] || 0) + 1);
69
+ var pending = Promise.all(files().map(function (f) {
70
+ return A.invoke('workspace:read-artifact', runId, f.name).then(function (res) {
71
+ return { name: f.name, title: f.title, text: res.text || '', truncated: res.truncated, error: res.error };
72
+ });
73
+ })).then(function (sections) {
74
+ if (A.state.runId !== runId || issue[panelId] !== token) { return; } // stale: superseded
75
+ window.AmicusRender.renderProseSections(A.$(bodyId), sections.map(function (s) {
76
+ return s.error ? { name: s.name, title: s.title, error: s.name + ' — ' + s.error } : s;
77
+ }));
78
+ A.$(panelId).dataset.loaded = '1'; // display/debug marker only — `loading` is the real gate
79
+ }, function (err) {
80
+ // ⚠️ T19-m2 (v4.7 PR7). Two-argument .then(onFulfilled, onRejected) — NOT a trailing
81
+ // .catch. workspace-verbs.js:76-84 already ruled on this exact construct: with a trailing
82
+ // .catch a THROW inside onFulfilled is routed here too, so a painter bug would be absorbed
83
+ // into a silent blank panel that ALSO evicts its own cache and therefore retries forever.
84
+ // With the two-argument form this handler only ever sees a genuinely rejected invoke().
85
+ // The `=== pending` self-check is load-bearing: without it a late rejection can evict a
86
+ // NEWER in-flight promise from `loading[panelId]`. That newer promise still resolves and
87
+ // paints fine on its own — it is not stranded — but the eviction leaves no cache entry
88
+ // behind for it, so a toggle that lands before it settles finds `loading[panelId]` empty
89
+ // and fires a spurious duplicate fetch. And the log is not optional — a silent
90
+ // eviction is the correct-but-silent degrade the product principle rejects.
91
+ if (loading[panelId] === pending) { delete loading[panelId]; }
92
+ console.error('workspace lazy panel: read-artifact failed for ' + panelId, err);
93
+ });
94
+ loading[panelId] = pending;
95
+ return pending;
96
+ }
97
+
98
+ /** Registered ONCE at boot (per panel id); reads the current run's spec off `loaders`. */
99
+ function proseLoader(panelId) {
100
+ var A = window.AmicusApp;
101
+ var panel = A.$(panelId);
102
+ panel.addEventListener('toggle', function () {
103
+ if (!panel.open) { return; }
104
+ var spec = loaders[panelId];
105
+ if (spec) { loadPanel(panelId, spec.bodyId, spec.files); }
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Rewrites the per-run spec map on every call. On a run CHANGE (tracked via the module-level
111
+ * `lastWiredRunId`, above), resets panel open/loaded state and drops the previous run's
112
+ * cached load promises — exactly what F09's stale-run protection needs. On a SAME-run call
113
+ * (Task 19, RN-5: renderDetail() calls this on every blind toggle too, and the live loop's
114
+ * terminal refresh) any panel the user already has open is instead refreshed in place — see
115
+ * Fix 1 below — never left showing stale-blind content, never collapsed. Registers no
116
+ * listeners itself.
117
+ */
118
+ function wireLazyPanels() {
119
+ var A = window.AmicusApp;
120
+ // ⚠️ Fix-wave (Fix 4): keyed off `A.state.detail.runId`, not `A.state.runId` — the latter is
121
+ // set synchronously at the top of openRun(), before its workspace:get-run reply lands, so an
122
+ // out-of-order reply could make the two diverge. workspace-app.js's own run-change gate
123
+ // (renderDetail(), above `d.runId`) reads off the SAME `state.detail.runId`, so the two
124
+ // provably agree on whether this is a run change.
125
+ var sameRun = A.state.detail.runId === lastWiredRunId;
126
+ if (!sameRun) {
127
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
128
+ var p = A.$(id);
129
+ p.dataset.loaded = '0';
130
+ p.open = false;
131
+ delete loading[id];
132
+ });
133
+ lastWiredRunId = A.state.detail.runId;
134
+ }
135
+ var bench = A.state.detail.run.bench || [];
136
+ var debated = !!A.state.detail.run.debate;
137
+ // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
138
+ // artifact is NOT translated into a friendly "not written yet" note anywhere in this
139
+ // read path — it lands in the panel verbatim, absolute host path and all. `run.debate` is
140
+ // seeded on run.json's FIRST write, so it's truthy on every --debate run, including ones
141
+ // where the re-vote wave never actually ran (no contested findings, cost ceiling, abort) —
142
+ // requesting revote-<model>.md speculatively in that (near-certain) case means one ugly
143
+ // error row per bench model for a condition that isn't an error at all. run-detail.js
144
+ // already computes a presence manifest (state.detail.artifacts) for exactly these
145
+ // allowlisted names via fs.statSync — filter on it instead of requesting known-absent
146
+ // files. Applies to review-/judge- too (the same latent gap, just plan-mandated rather
147
+ // than new).
148
+ var artifacts = A.state.detail.artifacts || {};
149
+ function present(name) { return !!(artifacts[name] && artifacts[name].present); }
150
+ // ⚠️ v4.4.1 RN-9: these two titles used to hand-roll `A.state.blind && label ? label : m`
151
+ // inline. Both now go through AmicusRender.display() — the single blind-flip definition the
152
+ // re-vote title below already used — so the next blind-mode ruling lands in one place instead
153
+ // of being re-applied by hand in every file that happens to render an identity.
154
+ 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) };
158
+ }).filter(function (f) { return present(f.name); });
159
+ } };
160
+ loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
161
+ // ⚠️ v4.4.1 RN-4: the presence filter is NOT optional here either. Without it, a run whose
162
+ // Stage 2 never ran (a one-seat bench, an abort before the cross-review, a cost ceiling)
163
+ // requested a file the manifest already knows is absent and rendered readRunArtifact's raw
164
+ // error string in the panel — "absolute host path and all", per this file's own round-2
165
+ // note above `present()`. reviews-panel and judges-panel have always filtered; this was the
166
+ // odd one out.
167
+ return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }]
168
+ .filter(function (f) { return present(f.name); });
169
+ } };
170
+ 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) };
174
+ });
175
+ if (debated) {
176
+ // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
177
+ // prose lives in revote-<model>.md (not judge-<model>.md). Included per bench model
178
+ // like judge-*.md above, but — per the presence filter — only when the manifest
179
+ // confirms the file actually exists (see the code-review note above `present()`).
180
+ // ⚠️ CODE REVIEW (round 2, finding 3): this title is new code (unlike the review-/
181
+ // judge- titles above, which mirror the brief verbatim), so it goes through
182
+ // AmicusRender.display() — the single blind-flip definition — rather than adding a
183
+ // fourth hand-rolled copy of the same ternary.
184
+ // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this name used to be recomputed via a
185
+ // bare sanitizeName(m) call, ignoring the disambiguation map entirely — for a colliding
186
+ // pair BOTH models resolved to the same bare revote-<sanitized>.md name, reintroducing
187
+ // for re-votes the exact cross-match bug Task 18 fixed for review-/judge-. Routed
188
+ // through resolveArtifactName(m, 'revote') like the other three sites; its built-in
189
+ // 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) };
193
+ }));
194
+ }
195
+ return files.filter(function (f) { return present(f.name); });
196
+ } };
197
+ // ⚠️ Fix-wave (Fix 1, RN-9): a same-run call (the blind toggle, or the live loop's
198
+ // running -> terminal refresh) must re-render any panel the user already has open, or it
199
+ // keeps showing content painted under the PREVIOUS blind state. renderProseSections()
200
+ // (workspace-render.js) clears its container before repainting, so this replaces sections
201
+ // in place rather than appending duplicates. Drop the cached promise first so loadPanel()
202
+ // actually re-fetches instead of returning its already-settled one.
203
+ if (sameRun) {
204
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
205
+ var p = A.$(id);
206
+ // ⚠️ T19-m1 (v4.7 PR7): the cache drop used to be INSIDE the `p.open` guard, so a panel
207
+ // the user had collapsed kept its settled promise across a blind flip — reopening it
208
+ // returned that promise and repainted the previous blind state with no new fetch (recon
209
+ // path A). Dropping unconditionally closes path A — but by itself it only CONVERTS path D
210
+ // (collapse mid-flight) into a race: it drops the cache entry even while that panel's
211
+ // fetch is still outstanding, so a reopen before it settles issues a SECOND concurrent
212
+ // fetch, and the orphaned first one has no fence but `runId` (unchanged for a same-run
213
+ // rewire). `issue` (declared above) and loadPanel()'s completion guard are what actually
214
+ // close D. Unconditional drop costs a re-read of that panel's artifacts on the next open;
215
+ // renderDetail fires on run open, blind toggle, and the live loop's terminal refresh only
216
+ // (the tick calls applyLive, not renderDetail), so this is not a per-poll storm.
217
+ delete loading[id];
218
+ if (p.open) { loadPanel(id, loaders[id].bodyId, loaders[id].files); }
219
+ });
220
+ }
221
+ }
222
+
223
+ // `loaders` stays module-private; drillIntoJudge (still in workspace-panels.js) needs to ask
224
+ // whether this run has a spec for a panel, and nothing else needs the map itself.
225
+ function panelSpec(panelId) { return loaders[panelId] || null; }
226
+
227
+ window.AmicusLazy = {
228
+ loadPanel: loadPanel,
229
+ proseLoader: proseLoader,
230
+ wireLazyPanels: wireLazyPanels,
231
+ panelSpec: panelSpec,
232
+ };
233
+ })();
@@ -76,7 +76,18 @@
76
76
  'aria-label': verdictTitle(cell),
77
77
  }, [cell.sym + (cell.isRaiser ? '*' : '')]);
78
78
  if (cell.verdict === 'dispute') {
79
- td.addEventListener('click', function () { onDrill(cell.judge, row.id); });
79
+ // ⚠️ T19-m2 (v4.7 PR7): drillIntoJudge's derived promise was discarded here, so a
80
+ // rejection — or a throw inside its own post-load body, which loadPanel's onRejected
81
+ // does NOT cover — escaped unhandled. `Promise.resolve(onDrill(...))` would NOT fix the
82
+ // synchronous half: onDrill(...) is evaluated BEFORE Promise.resolve sees it, so the
83
+ // throw escapes anyway (measured). Calling it inside the .then callback moves both the
84
+ // sync and async failure modes onto the chain. A trailing .catch is correct HERE
85
+ // (unlike loadPanel — see workspace-verbs.js:76-84) because there is no separate
86
+ // onFulfilled whose throw it could absorb: the callback IS the whole operation.
87
+ td.addEventListener('click', function () {
88
+ Promise.resolve().then(function () { return onDrill(cell.judge, row.id); })
89
+ .catch(function (err) { console.error('workspace matrix: drill into judge failed', err); });
90
+ });
80
91
  }
81
92
  cells.push(td);
82
93
  });
@@ -1,14 +1,23 @@
1
1
  /**
2
- * Council Workspace — lazy/prose panels + the matrix/verdict panel adapters
3
- * (v4.4 §5, ⚠️ DE-ROT F05 split of workspace-app.js).
2
+ * Council Workspace — name resolution + the matrix/verdict/seats panel adapters
3
+ * (v4.4 §5, ⚠️ DE-ROT F05 split of workspace-app.js). v4.7 PR7 (Task 1) moved the lazy
4
+ * prose-panel loading machinery (loaders/loading/lastWiredRunId state, loadPanel,
5
+ * proseLoader, wireLazyPanels) out to workspace-lazy.js — this file was pressed up against
6
+ * the 300-line size gate with the T19 stale-paint fixes still to land, the same treatment
7
+ * workspace-seats.js got in v4.6.2 PR4 (D8).
8
+ *
9
+ * Split line: this file owns NAME RESOLUTION (sanitizeName / resolveArtifactName — the RN-1
10
+ * disambiguation pair, pinned by tests/electron/workspace-ui-static.test.js) and the panel
11
+ * adapters; workspace-lazy.js owns WHEN and WHETHER an artifact read is issued and which
12
+ * reply is allowed to paint.
4
13
  *
5
14
  * Loads BEFORE workspace-app.js (md-lite → live-model → workspace-render →
6
- * workspace-matrix → workspace-seats → workspace-panels → workspace-verbs
7
- * workspace-app), so every function here reads `window.AmicusApp` /
8
- * `window.AmicusVerbs` at CALL time (never captured at this file's own load
9
- * time — neither namespace exists yet when this IIFE runs). window.AmicusApp
10
- * publishes its namespace at the top of its own boot, before calling into this
11
- * file, so by the time any function below actually executes, both are present.
15
+ * workspace-matrix → workspace-seats → workspace-lazy → workspace-panels
16
+ * workspace-verbs → workspace-app), so every function here reads `window.AmicusApp` /
17
+ * `window.AmicusVerbs` / `window.AmicusLazy` at CALL time (never captured at this file's
18
+ * own load time — none of those namespaces exist yet when this IIFE runs). window.AmicusApp
19
+ * publishes its namespace at the top of its own boot, before calling into this file, so by
20
+ * the time any function below actually executes, all three are present.
12
21
  */
13
22
  (function () {
14
23
  'use strict';
@@ -67,167 +76,10 @@
67
76
  }
68
77
  }
69
78
 
70
- // ---- lazy prose panels (spec §5.2: load on first open, cache) ---------
71
- // ⚠️ DE-ROT (F09): a NEW toggle listener stacking on every renderDetail() call is the bug this
72
- // shape exists to avoid — see wireLazyPanels()/proseLoader() below. Register the three
73
- // listeners ONCE at boot (workspace-app.js's boot block calls proseLoader per panel id) and
74
- // dispatch through this module-level `loaders` map, which renderDetail (via wireLazyPanels)
75
- // overwrites per run.
76
- //
77
- // ⚠️ PRE-FLIGHT (P4): the load is AWAITABLE — drillIntoJudge needs to know when it has
78
- // settled (the old code guessed with setTimeout(render, 300), which could fire before an
79
- // unbounded N-artifact IPC round trip finished and silently render nothing). loadPanel()
80
- // is idempotent per panel id and returns its in-flight promise; the promise cache
81
- // (`loading`) and the per-run spec (`loaders`) are both keyed by panel id, but only
82
- // `loading` is cleared by wireLazyPanels() — on a run CHANGE only (Task 19, RN-5) — and
83
- // that clearing is what stops F09's stale-run artifact requests.
84
- var loaders = {}; // panelId -> {bodyId, files} (rewritten per run by wireLazyPanels)
85
- var loading = {}; // panelId -> Promise (cleared per run by wireLazyPanels)
86
- // Task 19 (RN-5): the run wireLazyPanels() last reset panels/loading for — gates the reset
87
- // below to run CHANGES only. A same-run call (renderDetail() runs this on every blind toggle
88
- // too, and on the live loop's terminal refresh) instead refreshes any open panel (Fix 1).
89
- var lastWiredRunId = null;
90
-
91
- function loadPanel(panelId, bodyId, files) {
92
- var A = window.AmicusApp;
93
- if (loading[panelId]) { return loading[panelId]; }
94
- // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): this is the third
95
- // instance of the F09 class of bug (a stale async response overwriting shared DOM after
96
- // the user has navigated away) — already fixed once for the toggle-listener stack (F09
97
- // itself) and once for the fire-and-forget debate.json fetch in workspace-app.js (guards
98
- // with `if (state.runId !== runId) return;`). wireLazyPanels() clearing `loading[panelId]`
99
- // on every run switch permits a NEW request to be issued, but never fenced the PRIOR
100
- // request's eventual resolution — open reviews-panel on run A, switch to run B (which
101
- // issues its own request), and A's response — however late — used to overwrite whatever
102
- // B had just rendered. Capture the runId this request was issued for, and guard as the
103
- // FIRST statement of the completion handler, exactly like the debate.json fix.
104
- var runId = A.state.runId;
105
- loading[panelId] = Promise.all(files().map(function (f) {
106
- return A.invoke('workspace:read-artifact', runId, f.name).then(function (res) {
107
- return { name: f.name, title: f.title, text: res.text || '', truncated: res.truncated, error: res.error };
108
- });
109
- })).then(function (sections) {
110
- if (A.state.runId !== runId) { return; } // stale: superseded by a later run switch
111
- window.AmicusRender.renderProseSections(A.$(bodyId), sections.map(function (s) {
112
- return s.error ? { name: s.name, title: s.title, error: s.name + ' — ' + s.error } : s;
113
- }));
114
- A.$(panelId).dataset.loaded = '1'; // display/debug marker only — `loading` is the real gate
115
- });
116
- return loading[panelId];
117
- }
118
-
119
- /** Registered ONCE at boot (per panel id); reads the current run's spec off `loaders`. */
120
- function proseLoader(panelId) {
121
- var A = window.AmicusApp;
122
- var panel = A.$(panelId);
123
- panel.addEventListener('toggle', function () {
124
- if (!panel.open) { return; }
125
- var spec = loaders[panelId];
126
- if (spec) { loadPanel(panelId, spec.bodyId, spec.files); }
127
- });
128
- }
129
-
130
- /**
131
- * Rewrites the per-run spec map on every call. On a run CHANGE (tracked via the module-level
132
- * `lastWiredRunId`, above), resets panel open/loaded state and drops the previous run's
133
- * cached load promises — exactly what F09's stale-run protection needs. On a SAME-run call
134
- * (Task 19, RN-5: renderDetail() calls this on every blind toggle too, and the live loop's
135
- * terminal refresh) any panel the user already has open is instead refreshed in place — see
136
- * Fix 1 below — never left showing stale-blind content, never collapsed. Registers no
137
- * listeners itself.
138
- */
139
- function wireLazyPanels() {
140
- var A = window.AmicusApp;
141
- // ⚠️ Fix-wave (Fix 4): keyed off `A.state.detail.runId`, not `A.state.runId` — the latter is
142
- // set synchronously at the top of openRun(), before its workspace:get-run reply lands, so an
143
- // out-of-order reply could make the two diverge. workspace-app.js's own run-change gate
144
- // (renderDetail(), above `d.runId`) reads off the SAME `state.detail.runId`, so the two
145
- // provably agree on whether this is a run change.
146
- var sameRun = A.state.detail.runId === lastWiredRunId;
147
- if (!sameRun) {
148
- ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
149
- var p = A.$(id);
150
- p.dataset.loaded = '0';
151
- p.open = false;
152
- delete loading[id];
153
- });
154
- lastWiredRunId = A.state.detail.runId;
155
- }
156
- var bench = A.state.detail.run.bench || [];
157
- var debated = !!A.state.detail.run.debate;
158
- // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
159
- // artifact is NOT translated into a friendly "not written yet" note anywhere in this
160
- // read path — it lands in the panel verbatim, absolute host path and all. `run.debate` is
161
- // seeded on run.json's FIRST write, so it's truthy on every --debate run, including ones
162
- // where the re-vote wave never actually ran (no contested findings, cost ceiling, abort) —
163
- // requesting revote-<model>.md speculatively in that (near-certain) case means one ugly
164
- // error row per bench model for a condition that isn't an error at all. run-detail.js
165
- // already computes a presence manifest (state.detail.artifacts) for exactly these
166
- // allowlisted names via fs.statSync — filter on it instead of requesting known-absent
167
- // files. Applies to review-/judge- too (the same latent gap, just plan-mandated rather
168
- // than new).
169
- var artifacts = A.state.detail.artifacts || {};
170
- function present(name) { return !!(artifacts[name] && artifacts[name].present); }
171
- // ⚠️ v4.4.1 RN-9: these two titles used to hand-roll `A.state.blind && label ? label : m`
172
- // inline. Both now go through AmicusRender.display() — the single blind-flip definition the
173
- // re-vote title below already used — so the next blind-mode ruling lands in one place instead
174
- // of being re-applied by hand in every file that happens to render an identity.
175
- loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
176
- return bench.map(function (m) {
177
- var label = A.state.labelByModel[m];
178
- return { name: resolveArtifactName(m, 'review'), title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
179
- }).filter(function (f) { return present(f.name); });
180
- } };
181
- loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
182
- // ⚠️ v4.4.1 RN-4: the presence filter is NOT optional here either. Without it, a run whose
183
- // Stage 2 never ran (a one-seat bench, an abort before the cross-review, a cost ceiling)
184
- // requested a file the manifest already knows is absent and rendered readRunArtifact's raw
185
- // error string in the panel — "absolute host path and all", per this file's own round-2
186
- // note above `present()`. reviews-panel and judges-panel have always filtered; this was the
187
- // odd one out.
188
- return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }]
189
- .filter(function (f) { return present(f.name); });
190
- } };
191
- loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
192
- var files = bench.map(function (m) {
193
- var label = A.state.labelByModel[m];
194
- return { name: resolveArtifactName(m, 'judge'), title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
195
- });
196
- if (debated) {
197
- // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
198
- // prose lives in revote-<model>.md (not judge-<model>.md). Included per bench model
199
- // like judge-*.md above, but — per the presence filter — only when the manifest
200
- // confirms the file actually exists (see the code-review note above `present()`).
201
- // ⚠️ CODE REVIEW (round 2, finding 3): this title is new code (unlike the review-/
202
- // judge- titles above, which mirror the brief verbatim), so it goes through
203
- // AmicusRender.display() — the single blind-flip definition — rather than adding a
204
- // fourth hand-rolled copy of the same ternary.
205
- // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this name used to be recomputed via a
206
- // bare sanitizeName(m) call, ignoring the disambiguation map entirely — for a colliding
207
- // pair BOTH models resolved to the same bare revote-<sanitized>.md name, reintroducing
208
- // for re-votes the exact cross-match bug Task 18 fixed for review-/judge-. Routed
209
- // through resolveArtifactName(m, 'revote') like the other three sites; its built-in
210
- // legacy fallback keeps older detail payloads (no artifactsByModel map) correct too.
211
- files = files.concat(bench.map(function (m) {
212
- var label = A.state.labelByModel[m];
213
- return { name: resolveArtifactName(m, 'revote'), title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
214
- }));
215
- }
216
- return files.filter(function (f) { return present(f.name); });
217
- } };
218
- // ⚠️ Fix-wave (Fix 1, RN-9): a same-run call (the blind toggle, or the live loop's
219
- // running -> terminal refresh) must re-render any panel the user already has open, or it
220
- // keeps showing content painted under the PREVIOUS blind state. renderProseSections()
221
- // (workspace-render.js) clears its container before repainting, so this replaces sections
222
- // in place rather than appending duplicates. Drop the cached promise first so loadPanel()
223
- // actually re-fetches instead of returning its already-settled one.
224
- if (sameRun) {
225
- ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
226
- var p = A.$(id);
227
- if (p.open) { delete loading[id]; loadPanel(id, loaders[id].bodyId, loaders[id].files); }
228
- });
229
- }
230
- }
79
+ // ⚠️ v4.7 PR7 extraction: bodies moved verbatim to workspace-lazy.js
80
+ // (window.AmicusLazy), which loads immediately before this file.
81
+ function proseLoader(panelId) { window.AmicusLazy.proseLoader(panelId); }
82
+ function wireLazyPanels() { window.AmicusLazy.wireLazyPanels(); }
231
83
 
232
84
  // ⚠️ DE-ROT (F38): on a --debate run the FINAL tally.json is rebuilt from the debate's
233
85
  // replaced adjudications, so a matrix `dispute` cell can be a re-vote — gate per
@@ -240,9 +92,9 @@
240
92
  var A = window.AmicusApp;
241
93
  var panel = A.$('judges-panel');
242
94
  panel.open = true;
243
- var spec = loaders['judges-panel'];
95
+ var spec = window.AmicusLazy.panelSpec('judges-panel');
244
96
  if (!spec) { return Promise.resolve(); }
245
- return loadPanel('judges-panel', spec.bodyId, spec.files).then(function () {
97
+ return window.AmicusLazy.loadPanel('judges-panel', spec.bodyId, spec.files).then(function () {
246
98
  var rv = ((A.state.debate && A.state.debate.revotes) || []).find(function (r) {
247
99
  return r.judge === judgePair.model && r.id === findingId;
248
100
  });
@@ -290,5 +142,6 @@
290
142
  proseLoader: proseLoader,
291
143
  drillIntoJudge: drillIntoJudge,
292
144
  sanitizeName: sanitizeName,
145
+ resolveArtifactName: resolveArtifactName,
293
146
  };
294
147
  })();
@@ -36,6 +36,12 @@
36
36
  return el('span', { className: 'chip ' + (kind || ''), title: text }, [text]);
37
37
  }
38
38
 
39
+ /** td className for seat-table cell index i — one source for the three
40
+ * call sites (renderSeats create + update, renderDeadSeatRows). */
41
+ function seatCellClass(i) {
42
+ return i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '');
43
+ }
44
+
39
45
  /** Dual-name display flip (blind mode). pair = {model, label}. */
40
46
  function display(pair, blind) {
41
47
  if (!pair) { return '—'; }
@@ -177,11 +183,11 @@
177
183
  // did) while the stored `dataset.key` stays raw means a key containing `"` or `\` can
178
184
  // never match its own row — the lookup misses every tick and the row is re-appended
179
185
  // forever. A plain object lookup sidesteps the escaping problem entirely.
180
- var existing = {};
186
+ var existing = Object.create(null);
181
187
  Array.prototype.slice.call(tbody.children).forEach(function (row) {
182
188
  existing[row.dataset.key] = row;
183
189
  });
184
- var seen = {};
190
+ var seen = Object.create(null);
185
191
  seats.forEach(function (seat) {
186
192
  // ⚠️ DE-ROT (F37): `seat.id` is now always set by seatsFromRunStats (`model:role`), so
187
193
  // debate rebuttal/revote rows no longer collide with the seat row. The `|| seat.model`
@@ -197,7 +203,7 @@
197
203
  var cells = window.AmicusLive.seatCells(view, blindOn, labelOf);
198
204
  if (!row) {
199
205
  row = el('tr', { dataset: { key: key } }, cells.map(function (c, i) {
200
- return el('td', { className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
206
+ return el('td', { className: seatCellClass(i) }, [c]);
201
207
  }));
202
208
  tbody.appendChild(row);
203
209
  return;
@@ -205,12 +211,16 @@
205
211
  cells.forEach(function (c, i) {
206
212
  var td = row.children[i];
207
213
  if (td && td.textContent !== c) { td.textContent = c; }
208
- if (td) { td.className = i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : ''); }
214
+ if (td) { td.className = seatCellClass(i); }
209
215
  });
210
216
  });
211
217
  // RN-11 (v4.5): the keyed update added and removed rows but never MOVED
212
218
  // them, so table order was frozen at first render — wrong the moment a
213
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),
221
+ // so this is ~100 rows worst case on a debate run, every 1.5s. `existing` above is
222
+ // already the map an O(n) rewrite would need — reach for it only if this table ever
223
+ // renders unbounded rows.
214
224
  seats.forEach(function (seat, i) {
215
225
  var key = String(seat.id || seat.model);
216
226
  var current = tbody.children[i];
@@ -282,6 +292,6 @@
282
292
  el: el, chip: chip, display: display, relTime: relTime, renderRunList: renderRunList,
283
293
  renderHeaderChips: renderHeaderChips, renderGauge: renderGauge, renderStageRail: renderStageRail,
284
294
  renderSeats: renderSeats, renderBanner: renderBanner, renderCost: renderCost,
285
- renderProseSections: renderProseSections,
295
+ renderProseSections: renderProseSections, seatCellClass: seatCellClass,
286
296
  };
287
297
  })();