amicus 4.4.0 → 4.5.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 (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +162 -0
  3. package/README.md +17 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/DISTRIBUTION.md +234 -0
  6. package/docs/ROADMAP.md +226 -0
  7. package/docs/SHIMS.md +62 -0
  8. package/docs/architecture.md +104 -0
  9. package/docs/configuration.md +395 -0
  10. package/docs/council.md +970 -0
  11. package/docs/doc-system.md +92 -0
  12. package/docs/electron-testing.md +471 -0
  13. package/docs/jsdoc-setup.md +75 -0
  14. package/docs/opencode-integration.md +114 -0
  15. package/docs/publishing.md +60 -0
  16. package/docs/schemas.md +56 -0
  17. package/docs/testing.md +589 -0
  18. package/docs/troubleshooting.md +298 -0
  19. package/docs/usage.md +849 -0
  20. package/electron/fold.js +1 -1
  21. package/electron/main.js +4 -1
  22. package/electron/setup-ui-aliases.js +6 -6
  23. package/electron/workspace-ui/live-model.js +12 -1
  24. package/electron/workspace-ui/md-lite.js +52 -8
  25. package/electron/workspace-ui/workspace-app.js +39 -17
  26. package/electron/workspace-ui/workspace-matrix.js +46 -9
  27. package/electron/workspace-ui/workspace-panels.js +88 -19
  28. package/electron/workspace-ui/workspace-render.js +17 -1
  29. package/electron/workspace-ui/workspace-verbs.js +48 -2
  30. package/package.json +8 -3
  31. package/schemas/council-run-live.schema.json +1 -1
  32. package/schemas/council-run.schema.json +34 -0
  33. package/schemas/error.schema.json +1 -1
  34. package/schemas/event.schema.json +1 -1
  35. package/schemas/pack.schema.json +30 -0
  36. package/schemas/progress.schema.json +13 -1
  37. package/schemas/run-live.schema.json +1 -1
  38. package/schemas/run.schema.json +2 -1
  39. package/schemas/spend.schema.json +52 -4
  40. package/schemas/wave-live.schema.json +1 -1
  41. package/schemas/wave.schema.json +2 -1
  42. package/skills/second-opinion/SKILL.md +5 -0
  43. package/src/cli-handlers-council-run.js +51 -8
  44. package/src/cli-handlers-pack.js +238 -0
  45. package/src/cli-handlers-run.js +36 -8
  46. package/src/cli-handlers-spend.js +20 -2
  47. package/src/cli-handlers-template.js +53 -0
  48. package/src/cli-handlers-watch.js +11 -0
  49. package/src/cli.js +68 -5
  50. package/src/council/briefings-debate.js +27 -7
  51. package/src/council/briefings-stage2.js +155 -25
  52. package/src/council/briefings.js +24 -1
  53. package/src/council/findings.js +199 -9
  54. package/src/council/parse-stage2.js +10 -2
  55. package/src/council/presets-cli.js +23 -11
  56. package/src/council/report.js +19 -8
  57. package/src/council/run-assemble.js +42 -1
  58. package/src/council/run-budget.js +64 -11
  59. package/src/council/run-chair.js +4 -1
  60. package/src/council/run-debate.js +4 -2
  61. package/src/council/run-finalize.js +102 -0
  62. package/src/council/run-launch.js +29 -1
  63. package/src/council/run-server.js +248 -0
  64. package/src/council/run-stage2.js +118 -0
  65. package/src/council/run-stages.js +134 -110
  66. package/src/council/run-state.js +40 -1
  67. package/src/council/run.js +45 -47
  68. package/src/council/tally.js +10 -0
  69. package/src/headless.js +180 -7
  70. package/src/mcp-council-run.js +108 -4
  71. package/src/mcp-server.js +203 -7
  72. package/src/mcp-tools.js +15 -5
  73. package/src/observe/council-legs.js +60 -3
  74. package/src/observe/live-doc.js +18 -1
  75. package/src/observe/watch-render.js +4 -1
  76. package/src/pack/pack-cli.js +38 -0
  77. package/src/pack/pack-forward.js +96 -0
  78. package/src/pack/pack-resolve.js +297 -0
  79. package/src/pack/pack-store.js +130 -0
  80. package/src/pack/pack-validate.js +113 -0
  81. package/src/sidecar/child-sessions.js +1 -2
  82. package/src/sidecar/fanout-leg-fallback.js +69 -21
  83. package/src/sidecar/fanout-leg.js +6 -0
  84. package/src/sidecar/fanout-signals.js +61 -0
  85. package/src/sidecar/fanout-wave-io.js +75 -0
  86. package/src/sidecar/fanout.js +82 -74
  87. package/src/sidecar/progress-fields.js +26 -4
  88. package/src/sidecar/progress.js +42 -1
  89. package/src/sidecar/session-utils.js +23 -14
  90. package/src/sidecar/start.js +5 -4
  91. package/src/sidecar/workspace-auto-open.js +69 -0
  92. package/src/sidecar/workspace-window.js +46 -1
  93. package/src/spend-query.js +17 -5
  94. package/src/template/apply.js +88 -0
  95. package/src/template/render.js +86 -0
  96. package/src/template/store.js +106 -0
  97. package/src/utils/config.js +65 -25
  98. package/src/utils/error-doc.js +5 -0
  99. package/src/utils/lifecycle.js +37 -1
  100. package/src/utils/path-fence.js +39 -1
  101. package/src/utils/pricing.js +26 -10
  102. package/src/utils/result-schema-rebuild.js +1 -0
  103. package/src/utils/result-schema.js +8 -2
  104. package/src/utils/server-setup.js +79 -1
  105. package/src/utils/spend-ledger.js +24 -3
  106. package/src/workspace/artifact-guard.js +66 -7
  107. package/src/workspace/fold-format.js +33 -4
  108. package/src/workspace/live-normalize.js +28 -15
  109. package/src/workspace/run-detail.js +13 -1
package/electron/fold.js CHANGED
@@ -66,7 +66,7 @@ function createFoldHandler(state) {
66
66
  `Session: ${state.sessionId || state.taskId}`,
67
67
  `Client: ${state.client}`,
68
68
  `CWD: ${state.cwd}`,
69
- `Mode: interactive`,
69
+ 'Mode: interactive',
70
70
  '---',
71
71
  summary || 'Session ended without summary.'
72
72
  ].join('\n');
package/electron/main.js CHANGED
@@ -142,7 +142,10 @@ function createAmicusWindow() {
142
142
  let updateInfo = null;
143
143
  const updateInfoRaw = process.env.AMICUS_UPDATE_INFO;
144
144
  if (updateInfoRaw) {
145
- try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {}
145
+ try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {
146
+ // A malformed AMICUS_UPDATE_INFO is not worth failing launch over:
147
+ // updateInfo stays null and the direct check below takes over.
148
+ }
146
149
  }
147
150
  if (!updateInfo) {
148
151
  const { getUpdateInfo, initUpdateCheck } = require('../src/utils/updater');
@@ -22,7 +22,7 @@ const ALIAS_GROUPS = [
22
22
  * @returns {string} HTML fragment with search, groups, rows, and add button
23
23
  */
24
24
  function buildAliasEditorHTML(aliases) {
25
- const searchInput = `<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">`;
25
+ const searchInput = '<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">';
26
26
 
27
27
  const groups = ALIAS_GROUPS.map(group => {
28
28
  const rows = group.keys
@@ -31,10 +31,10 @@ function buildAliasEditorHTML(aliases) {
31
31
  const model = aliases[key];
32
32
  return `<div class="alias-row" data-alias="${key}">` +
33
33
  `<span class="alias-name">${key}</span>` +
34
- `<span class="alias-arrow">\u2192</span>` +
34
+ '<span class="alias-arrow">\u2192</span>' +
35
35
  `<span class="alias-model">${model}</span>` +
36
36
  `<button class="alias-delete" data-alias="${key}">\u00d7</button>` +
37
- `</div>`;
37
+ '</div>';
38
38
  }).join('\n ');
39
39
 
40
40
  const count = group.keys.filter(key => aliases[key] !== undefined).length;
@@ -51,9 +51,9 @@ function buildAliasEditorHTML(aliases) {
51
51
  const exampleModel = aliases[exampleAlias] || 'openrouter/google/gemini-3.1-flash-lite-preview';
52
52
 
53
53
  // SVG icons for the example box
54
- const terminalIcon = `<svg class="alias-icon-accent" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" class="alias-icon-faint-path" stroke-width="1.5" stroke-linecap="round"/></svg>`;
55
- const arrowIcon = `<svg class="alias-icon-accent" width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
56
- const modelIcon = `<svg class="alias-icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke-width="1.5"/><path d="M8 8v3" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke-width="1" stroke-linecap="round"/></svg>`;
54
+ const terminalIcon = '<svg class="alias-icon-accent" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" class="alias-icon-faint-path" stroke-width="1.5" stroke-linecap="round"/></svg>';
55
+ const arrowIcon = '<svg class="alias-icon-accent" width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
56
+ const modelIcon = '<svg class="alias-icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke-width="1.5"/><path d="M8 8v3" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke-width="1" stroke-linecap="round"/></svg>';
57
57
 
58
58
  const exampleBox = `<div class="routing-example">
59
59
  <div class="example-label">How it works</div>
@@ -11,7 +11,10 @@
11
11
  // or idle-timed-out run would poll forever and never flip to its terminal rendering.
12
12
  // Must stay byte-identical to src/workspace/run-detail.js TERMINAL_STATUSES, which itself
13
13
  // mirrors the shipped src/observe/live-doc.js TERMINAL set. A drift pin asserts this.
14
- var TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout'];
14
+ // ⚠️ v4.4.1 A1: 'timed-out' added alongside 'timeout'. Both spellings are real and are written
15
+ // by different producers — see src/observe/live-doc.js:18. Inert for the workspace (a council
16
+ // run.json's status vocabulary is aborted|complete|error|partial), carried for byte-identity.
17
+ var TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout'];
15
18
 
16
19
  // ⚠️ DE-ROT (F41): STAGE_LABELS must be mirrored here too. The live loop labels stages that
17
20
  // START AFTER the run was opened, and those names are absent from the frozen derived.stageRail,
@@ -54,6 +57,14 @@
54
57
  // leg (terminal rows from runStats are already alias-only, so the fallback is exact there).
55
58
  var alias = seat.modelInput || seat.model;
56
59
  var label = labelOf ? labelOf(alias) : null;
60
+ // ⚠️ v4.4.1 RN-9: this hand-rolled flip is LOAD-BEARING — do NOT "fix" it into
61
+ // AmicusRender.display({model: seat.model, label: label}, blindOn). The other two copies
62
+ // (workspace-panels.js's review/judge titles) were routed through display() because their
63
+ // pair's `model` IS the identity they must print. Here it is not: the seat's printable
64
+ // identity is the council ALIAS resolved on the line above (F36 — a live leg's `seat.model`
65
+ // is the RESOLVED executable id, e.g. `google/gemini-2.5-pro`, which labelMap never keys on),
66
+ // so display()'s blind-OFF arm would print the resolved id and undo F36. This module is also
67
+ // node-tested with NO DOM and no window.AmicusRender to call. Deliberate third copy.
57
68
  var name = blindOn && label ? label : alias;
58
69
  var tokens = (seat.tokensIn === null || seat.tokensIn === undefined) &&
59
70
  (seat.tokensOut === null || seat.tokensOut === undefined)
@@ -15,16 +15,49 @@
15
15
  var UL_RE = /^\s*[-*]\s+/;
16
16
  var OL_RE = /^\s*\d+[.)]\s+/;
17
17
 
18
+ /**
19
+ * Split one line into literal / inline-code segments in a SINGLE linear pass.
20
+ *
21
+ * ⚠️ v4.4.1 A1/D1 (Confirmed 4/4), with its SEVERITY CLAIM CORRECTED. The
22
+ * previous form re-`exec`ed a freshly sliced `rest` each iteration
23
+ * (`rest = rest.slice(...)`) — quadratic as WRITTEN, but not as it ran. V8's
24
+ * `String.prototype.slice` returns a SlicedString (parent pointer + offset),
25
+ * not a copy, so the old code measured ~8 ms at artifact-guard.js's 200 KB cap
26
+ * and scaled LINEARLY (~2x per doubling out to 2.4 MB). A deliberately-copying
27
+ * control took 78,232 ms at that same 200 KB — that is the freeze the finding
28
+ * described, and it is not what shipped. Numbers:
29
+ * `.superpowers/sdd/task-10-report.md:127-148`.
30
+ *
31
+ * What the rewrite removes is therefore a silent dependence on an undocumented
32
+ * engine optimisation that nothing here states or tests — a latent PORTABILITY
33
+ * hazard on any engine without sliced strings — and not a live performance
34
+ * defect or an exploitable-DoS-that-was. The `lastIndex` cursor walks the
35
+ * ORIGINAL string once and never copies a tail, so linearity is now a property
36
+ * of the algorithm rather than of the engine.
37
+ *
38
+ * Output is identical to the old function for every input: the pattern is
39
+ * context-free — no `^`, `\b`, lookaround or backreference — so a /g scan
40
+ * resuming at `lastIndex` lands on exactly the same match positions that
41
+ * re-`exec`ing the remainder did. The three boundary cases match too: no
42
+ * match at all yields one literal segment, a trailing match yields no empty
43
+ * tail segment, and empty input yields [].
44
+ *
45
+ * The regex is constructed per call and deliberately NOT hoisted to module
46
+ * scope: a /g regex carries mutable `lastIndex`, so one shared instance would
47
+ * leak cursor state between calls and silently drop spans.
48
+ */
18
49
  function parseInline(text) {
19
50
  var out = [];
20
- var rest = String(text);
21
- while (rest.length) {
22
- var m = /`([^`\n]+)`/.exec(rest);
23
- if (!m) { out.push({ code: false, text: rest }); break; }
24
- if (m.index > 0) { out.push({ code: false, text: rest.slice(0, m.index) }); }
51
+ var s = String(text);
52
+ var re = /`([^`\n]+)`/g;
53
+ var pos = 0;
54
+ var m;
55
+ while ((m = re.exec(s)) !== null) {
56
+ if (m.index > pos) { out.push({ code: false, text: s.slice(pos, m.index) }); }
25
57
  out.push({ code: true, text: m[1] });
26
- rest = rest.slice(m.index + m[0].length);
58
+ pos = re.lastIndex;
27
59
  }
60
+ if (pos < s.length) { out.push({ code: false, text: s.slice(pos) }); }
28
61
  return out;
29
62
  }
30
63
 
@@ -43,7 +76,11 @@
43
76
  continue;
44
77
  }
45
78
  var h = H_RE.exec(line);
46
- if (h) { blocks.push({ t: 'h', level: h[1].length, text: h[2] }); i += 1; continue; }
79
+ // ⚠️ v4.4.1 D3: trim the heading text. H_RE's `\s+` eats the run of
80
+ // whitespace after the hashes, but `(.*)$` keeps everything to end of
81
+ // line — so `# Title ` rendered a heading with trailing blanks baked
82
+ // into its text node.
83
+ if (h) { blocks.push({ t: 'h', level: h[1].length, text: h[2].trim() }); i += 1; continue; }
47
84
  if (UL_RE.test(line)) {
48
85
  var ul = [];
49
86
  while (i < lines.length && UL_RE.test(lines[i])) { ul.push(lines[i].replace(UL_RE, '')); i += 1; }
@@ -91,7 +128,14 @@
91
128
  var b = blocks[i];
92
129
  var el;
93
130
  if (b.t === 'h') {
94
- el = d.createElement('h' + Math.min(6, b.level + 2));
131
+ // ⚠️ v4.4.1 D4: the `Math.min(6, …)` that used to wrap this was
132
+ // unreachable — H_RE's `#{1,4}` bounds level to 1–4, so the tag is
133
+ // always h3–h6 and the clamp could never fire. Dead defensive code is
134
+ // worse than none here: it made the h6 ceiling look enforced when the
135
+ // real guarantee lives in H_RE. If that `#{1,4}` is ever widened, THIS
136
+ // line must widen with it — h7 is not an element. The
137
+ // `'#'.repeat(4) + ' X'` → h6 test pins the true boundary.
138
+ el = d.createElement('h' + (b.level + 2));
95
139
  applyInline(el, parseInline(b.text), d);
96
140
  } else if (b.t === 'code') {
97
141
  el = d.createElement('pre');
@@ -27,6 +27,13 @@
27
27
  // null on a non-debate run, an aborted/skipped debate, or a parse failure —
28
28
  // drillIntoJudge's judge-*.md fallback covers all three.
29
29
  blind: false,
30
+ // Task 19 (RN-5) + fix-wave (RN-5 amendment): the (run id, status) pair renderDetail() last
31
+ // computed state.blind's default for. Together they gate the recompute (in renderDetail(),
32
+ // below) to a run CHANGE or a STATUS change only: a same-run/same-status re-render (the
33
+ // blind toggle) keeps the user's own choice, while a same-run/CHANGED-status re-render (the
34
+ // live loop's running -> terminal refresh, or the abort-confirm re-read) still auto-reveals.
35
+ detailRunId: null,
36
+ detailRunStatus: null,
30
37
  labelByModel: {},
31
38
  listTimer: null,
32
39
  liveTimer: null, // LIVE (Task 15)
@@ -103,8 +110,23 @@
103
110
  return;
104
111
  }
105
112
 
106
- // blind default: computed ONCE per run-open from status (resolution 9)
107
- state.blind = window.AmicusLive.defaultBlind(d.run.status);
113
+ // blind default: computed from status (resolution 9) — only on a run CHANGE or a STATUS
114
+ // change for the same run. Task 19 (RN-5): a same-run/same-status re-render
115
+ // (renderDetail_preserveBlind, below, calls straight back in here on every blind toggle)
116
+ // must keep the user's own choice instead of recomputing the default every call —
117
+ // recomputing unconditionally is what forced the old code to paint twice and, via
118
+ // wireLazyPanels() a few lines down, collapse any lazy panel the user had open.
119
+ // ⚠️ Fix-wave (RN-5 amendment, controller ruling): run id ALONE also suppressed the
120
+ // pre-existing running -> terminal auto-reveal, since the live loop's terminal refresh
121
+ // (workspace-verbs.js's startLiveLoop tick) and the abort-confirm re-read both call
122
+ // openRun() on the SAME run id — same run, but a real status transition. Keying on run id
123
+ // AND status recomputes (auto-reveals) on that transition while still preserving a same-
124
+ // run/same-status call (the blind toggle).
125
+ if (state.detailRunId !== d.runId || state.detailRunStatus !== d.run.status) {
126
+ state.blind = window.AmicusLive.defaultBlind(d.run.status);
127
+ state.detailRunId = d.runId;
128
+ state.detailRunStatus = d.run.status;
129
+ }
108
130
  $('blind-toggle').checked = state.blind;
109
131
  state.labelByModel = {};
110
132
  d.derived.names.forEach(function (p) { state.labelByModel[p.model] = p.label; });
@@ -178,7 +200,6 @@
178
200
  });
179
201
 
180
202
  function renderDetail_preserveBlind() {
181
- var keep = state.blind;
182
203
  // ⚠️ Fix-wave item 1: renderDetail() itself early-returns safely for an unreadable run
183
204
  // (!d || d.error || !d.derived) — but this wrapper used to run past that guard
184
205
  // unconditionally, dereferencing the (nonexistent) derived model via renderSeatsPanel()
@@ -187,22 +208,22 @@
187
208
  // the error branch unhides #run-view before the derived-model guard, so the Blind
188
209
  // checkbox is live with nothing behind it.
189
210
  if (!state.detail || state.detail.error || !state.detail.derived) {
190
- $('blind-toggle').checked = keep;
211
+ $('blind-toggle').checked = state.blind;
191
212
  return;
192
213
  }
214
+ // ⚠️ Task 19 (RN-5): this used to call renderDetail() (which unconditionally stomped
215
+ // state.blind back to the run's status default), restore the user's pre-call value,
216
+ // and then re-paint header chips/seats/matrix/verdict/cost a SECOND time to compensate
217
+ // for the first call having painted with the wrong (default) blind state — a double
218
+ // paint, and (via wireLazyPanels(), called inside that first renderDetail()) a collapse
219
+ // of any lazy panel the user had open. Fixed at the root instead of compensated for:
220
+ // renderDetail() now recomputes the blind default only on a run CHANGE or a STATUS change
221
+ // (state.detailRunId/state.detailRunStatus, above) — this call changes neither, so it
222
+ // keeps state.blind exactly as the change listener above just set it, and
223
+ // workspace-panels.js's wireLazyPanels() (its own same-run guard, `lastWiredRunId`)
224
+ // refreshes any open panel in place rather than collapsing it (fix-wave, Fix 1). One
225
+ // renderDetail() call now paints correctly the first time — nothing left to restore.
193
226
  renderDetail();
194
- state.blind = keep;
195
- $('blind-toggle').checked = keep;
196
- // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): renderDetail() (just
197
- // called above) resets state.blind to the run's DEFAULT before this function restores the
198
- // user's chosen `keep` value — renderHeaderChips was painted during that inner call with
199
- // the (temporarily wrong) default blind state and, unlike seats/matrix/verdict/cost below,
200
- // was never repainted afterward. Re-render it here too, now that state.blind is correct.
201
- window.AmicusRender.renderHeaderChips($('run-chips'), state.detail.run, state.blind, labelOf);
202
- P.renderSeatsPanel();
203
- P.renderMatrixPanel();
204
- P.renderVerdictPanel();
205
- window.AmicusRender.renderCost($('cost-body'), state.detail.derived.cost, state.blind, labelOf);
206
227
  }
207
228
 
208
229
  $('run-list').addEventListener('keydown', function (e) {
@@ -227,7 +248,8 @@
227
248
 
228
249
  // ⚠️ PRE-FLIGHT (P4) + DE-ROT (F09): register the three prose `toggle` listeners exactly ONCE,
229
250
  // here at boot. wireLazyPanels() (called from renderDetail, on every run-open and blind-toggle)
230
- // only rewrites the per-run `loaders` spec map from now onit never adds a listener.
251
+ // rewrites the per-run `loaders` spec map every call andfix-wave, Fix 1 refreshes any
252
+ // already-open lazy panel in place on a same-run call; it never adds a listener.
231
253
  P.proseLoader('reviews-panel');
232
254
  P.proseLoader('bundle-panel');
233
255
  P.proseLoader('judges-panel');
@@ -175,20 +175,57 @@
175
175
  return chairHost;
176
176
  }
177
177
 
178
- /** Wrap every text-node occurrence of needle in <mark> (DOM-safe highlight). */
178
+ /**
179
+ * Wrap EVERY occurrence of `needle` in a `<mark>` (DOM-safe: splitText + replaceChild,
180
+ * never innerHTML).
181
+ *
182
+ * ⚠️ v4.4.1 RN-3 + DOC-6: this used to do exactly ONE `indexOf`/`splitText` per collected
183
+ * text node, so a finding id mentioned twice inside a single text node was highlighted once
184
+ * — and the reader, drilling in from a dispute cell, believed they had seen every reference
185
+ * to it in that judge's prose. This docblock nonetheless promised "every occurrence" (DOC-6),
186
+ * which is what made two `wsgate04` reviewers file the same bug from opposite directions.
187
+ * Behaviour and doc now agree.
188
+ *
189
+ * The rescan continues from `tail`, not from `cursor`, because `splitText` MUTATES the node
190
+ * being walked: the first call truncates `cursor` to the text BEFORE the match and returns the
191
+ * match-plus-remainder; the second peels the remainder off into a NEW node the TreeWalker's
192
+ * already-collected list does not contain. Advancing to that new node is what makes the loop
193
+ * both complete (it can reach later matches) and terminating (it can never re-find the match
194
+ * it just replaced).
195
+ *
196
+ * NOT idempotent — a second call re-walks the text nodes inside the marks this one created and
197
+ * nests a second `<mark>`. drillIntoJudge (workspace-panels.js) calls clearHighlight() first
198
+ * for exactly that reason.
199
+ *
200
+ * ⚠️ v4.4.1 M1: clearHighlight() (below) unwraps each `<mark>` back into a plain text node but
201
+ * never calls `normalize()`, so the sibling text nodes either side of the old mark are left
202
+ * un-merged — the drill-clear-drill cycle fragments the prose a little more every time. Because
203
+ * the scan above only ever calls `.indexOf`/`.splitText` on ONE text node at a time, a needle
204
+ * whose match now straddles one of those leftover boundaries is invisible to it — silently
205
+ * under-marking on a re-drilled panel, the opposite of this docblock's "every occurrence"
206
+ * promise. `container.normalize()` below re-merges adjacent text nodes before every scan, so
207
+ * highlightText is robust to fragmentation regardless of how the container got that way.
208
+ */
179
209
  function highlightText(container, needle) {
180
- if (!needle) { return; }
210
+ if (!needle) { return; } // also the loop's termination guard: a 0-length needle never advances
211
+ // v4.4.1 M1: undo any un-merged fragmentation clearHighlight() left behind (it does not
212
+ // normalize) — without this, a needle whose match spans a leftover node boundary is missed.
213
+ container.normalize();
181
214
  var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
182
215
  var nodes = [];
183
216
  while (walker.nextNode()) { nodes.push(walker.currentNode); }
184
217
  nodes.forEach(function (node) {
185
- var idx = node.nodeValue.indexOf(needle);
186
- if (idx === -1) { return; }
187
- var after = node.splitText(idx);
188
- after.splitText(needle.length);
189
- var mark = document.createElement('mark');
190
- mark.textContent = needle;
191
- after.parentNode.replaceChild(mark, after);
218
+ var cursor = node;
219
+ for (;;) {
220
+ var idx = cursor.nodeValue.indexOf(needle);
221
+ if (idx === -1) { return; }
222
+ var match = cursor.splitText(idx); // `match` now starts with the needle
223
+ var tail = match.splitText(needle.length); // `tail` is everything after it
224
+ var mark = document.createElement('mark');
225
+ mark.textContent = needle;
226
+ match.parentNode.replaceChild(mark, match);
227
+ cursor = tail;
228
+ }
192
229
  });
193
230
  }
194
231
 
@@ -19,6 +19,24 @@
19
19
  // inverted back to the model id that keys state.labelByModel, so blind labels would break.
20
20
  function sanitizeName(model) { return String(model).replace(/[^a-zA-Z0-9._-]/g, '-'); }
21
21
 
22
+ // ⚠️ Task 18 (RN-1): review-/judge- filenames used to be recomputed here via a bare
23
+ // sanitizeName(model) call, which is NOT injective — two distinct bench models that sanitize
24
+ // to the same name would both resolve to the SAME filename, so drillIntoJudge's
25
+ // `[data-artifact="..."]` lookup handed back whichever section matched first (model A's prose
26
+ // rendered under model B's name). derived.artifactsByModel (src/workspace/artifact-guard.js's
27
+ // artifactAllowlist, threaded through by run-detail.js) already carries the disambiguated
28
+ // (possibly `~2`/`~3`-suffixed) name per raw model — consult it FIRST. Fall back to the legacy
29
+ // computation only when the map itself is absent: older detail payloads (pre-v4.5 runs,
30
+ // live-doc consumers not yet updated to build the map) never carry it, and re-deriving via
31
+ // sanitizeName is exactly what those payloads always did, so it stays correct for them too.
32
+ function resolveArtifactName(model, kind) {
33
+ var A = window.AmicusApp;
34
+ var map = A.state.detail.derived && A.state.detail.derived.artifactsByModel;
35
+ var entry = map && map[model];
36
+ if (entry && entry[kind]) { return entry[kind]; }
37
+ return kind + '-' + sanitizeName(model) + '.md';
38
+ }
39
+
22
40
  function renderSeatsPanel() {
23
41
  var A = window.AmicusApp;
24
42
  var d = A.state.detail;
@@ -60,12 +78,16 @@
60
78
  // ⚠️ PRE-FLIGHT (P4): the load is AWAITABLE — drillIntoJudge needs to know when it has
61
79
  // settled (the old code guessed with setTimeout(render, 300), which could fire before an
62
80
  // unbounded N-artifact IPC round trip finished and silently render nothing). loadPanel()
63
- // is idempotent per panel id and returns its in-flight promise; both the promise cache
64
- // (`loading`) and the per-run spec (`loaders`) are keyed by panel id and cleared/overwritten
65
- // by wireLazyPanels() on every run-open that clearing is what stops F09's stale-run
66
- // artifact requests.
81
+ // is idempotent per panel id and returns its in-flight promise; the promise cache
82
+ // (`loading`) and the per-run spec (`loaders`) are both keyed by panel id, but only
83
+ // `loading` is cleared by wireLazyPanels() on a run CHANGE only (Task 19, RN-5) and
84
+ // that clearing is what stops F09's stale-run artifact requests.
67
85
  var loaders = {}; // panelId -> {bodyId, files} (rewritten per run by wireLazyPanels)
68
86
  var loading = {}; // panelId -> Promise (cleared per run by wireLazyPanels)
87
+ // Task 19 (RN-5): the run wireLazyPanels() last reset panels/loading for — gates the reset
88
+ // below to run CHANGES only. A same-run call (renderDetail() runs this on every blind toggle
89
+ // too, and on the live loop's terminal refresh) instead refreshes any open panel (Fix 1).
90
+ var lastWiredRunId = null;
69
91
 
70
92
  function loadPanel(panelId, bodyId, files) {
71
93
  var A = window.AmicusApp;
@@ -107,18 +129,31 @@
107
129
  }
108
130
 
109
131
  /**
110
- * Rewrites the per-run spec map and drops the previous run's cached load promises this
111
- * is precisely what stops F09's stale-run artifact requests. Safe to call on every
112
- * renderDetail() (run-open and blind-toggle alike); it registers no listeners itself.
132
+ * Rewrites the per-run spec map on every call. On a run CHANGE (tracked via the module-level
133
+ * `lastWiredRunId`, above), resets panel open/loaded state and drops the previous run's
134
+ * cached load promises — exactly what F09's stale-run protection needs. On a SAME-run call
135
+ * (Task 19, RN-5: renderDetail() calls this on every blind toggle too, and the live loop's
136
+ * terminal refresh) any panel the user already has open is instead refreshed in place — see
137
+ * Fix 1 below — never left showing stale-blind content, never collapsed. Registers no
138
+ * listeners itself.
113
139
  */
114
140
  function wireLazyPanels() {
115
141
  var A = window.AmicusApp;
116
- ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
117
- var p = A.$(id);
118
- p.dataset.loaded = '0';
119
- p.open = false;
120
- delete loading[id];
121
- });
142
+ // ⚠️ Fix-wave (Fix 4): keyed off `A.state.detail.runId`, not `A.state.runId` — the latter is
143
+ // set synchronously at the top of openRun(), before its workspace:get-run reply lands, so an
144
+ // out-of-order reply could make the two diverge. workspace-app.js's own run-change gate
145
+ // (renderDetail(), above `d.runId`) reads off the SAME `state.detail.runId`, so the two
146
+ // provably agree on whether this is a run change.
147
+ var sameRun = A.state.detail.runId === lastWiredRunId;
148
+ if (!sameRun) {
149
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
150
+ var p = A.$(id);
151
+ p.dataset.loaded = '0';
152
+ p.open = false;
153
+ delete loading[id];
154
+ });
155
+ lastWiredRunId = A.state.detail.runId;
156
+ }
122
157
  var bench = A.state.detail.run.bench || [];
123
158
  var debated = !!A.state.detail.run.debate;
124
159
  // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
@@ -134,19 +169,30 @@
134
169
  // than new).
135
170
  var artifacts = A.state.detail.artifacts || {};
136
171
  function present(name) { return !!(artifacts[name] && artifacts[name].present); }
172
+ // ⚠️ v4.4.1 RN-9: these two titles used to hand-roll `A.state.blind && label ? label : m`
173
+ // inline. Both now go through AmicusRender.display() — the single blind-flip definition the
174
+ // re-vote title below already used — so the next blind-mode ruling lands in one place instead
175
+ // of being re-applied by hand in every file that happens to render an identity.
137
176
  loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
138
177
  return bench.map(function (m) {
139
178
  var label = A.state.labelByModel[m];
140
- return { name: 'review-' + sanitizeName(m) + '.md', title: (A.state.blind && label ? label : m) };
179
+ return { name: resolveArtifactName(m, 'review'), title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
141
180
  }).filter(function (f) { return present(f.name); });
142
181
  } };
143
182
  loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
144
- return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }];
183
+ // ⚠️ v4.4.1 RN-4: the presence filter is NOT optional here either. Without it, a run whose
184
+ // Stage 2 never ran (a one-seat bench, an abort before the cross-review, a cost ceiling)
185
+ // requested a file the manifest already knows is absent and rendered readRunArtifact's raw
186
+ // error string in the panel — "absolute host path and all", per this file's own round-2
187
+ // note above `present()`. reviews-panel and judges-panel have always filtered; this was the
188
+ // odd one out.
189
+ return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }]
190
+ .filter(function (f) { return present(f.name); });
145
191
  } };
146
192
  loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
147
193
  var files = bench.map(function (m) {
148
194
  var label = A.state.labelByModel[m];
149
- return { name: 'judge-' + sanitizeName(m) + '.md', title: 'Judge ' + (A.state.blind && label ? label : m) };
195
+ return { name: resolveArtifactName(m, 'judge'), title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
150
196
  });
151
197
  if (debated) {
152
198
  // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
@@ -157,13 +203,31 @@
157
203
  // judge- titles above, which mirror the brief verbatim), so it goes through
158
204
  // AmicusRender.display() — the single blind-flip definition — rather than adding a
159
205
  // fourth hand-rolled copy of the same ternary.
206
+ // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this name used to be recomputed via a
207
+ // bare sanitizeName(m) call, ignoring the disambiguation map entirely — for a colliding
208
+ // pair BOTH models resolved to the same bare revote-<sanitized>.md name, reintroducing
209
+ // for re-votes the exact cross-match bug Task 18 fixed for review-/judge-. Routed
210
+ // through resolveArtifactName(m, 'revote') like the other three sites; its built-in
211
+ // legacy fallback keeps older detail payloads (no artifactsByModel map) correct too.
160
212
  files = files.concat(bench.map(function (m) {
161
213
  var label = A.state.labelByModel[m];
162
- return { name: 'revote-' + sanitizeName(m) + '.md', title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
214
+ return { name: resolveArtifactName(m, 'revote'), title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
163
215
  }));
164
216
  }
165
217
  return files.filter(function (f) { return present(f.name); });
166
218
  } };
219
+ // ⚠️ Fix-wave (Fix 1, RN-9): a same-run call (the blind toggle, or the live loop's
220
+ // running -> terminal refresh) must re-render any panel the user already has open, or it
221
+ // keeps showing content painted under the PREVIOUS blind state. renderProseSections()
222
+ // (workspace-render.js) clears its container before repainting, so this replaces sections
223
+ // in place rather than appending duplicates. Drop the cached promise first so loadPanel()
224
+ // actually re-fetches instead of returning its already-settled one.
225
+ if (sameRun) {
226
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
227
+ var p = A.$(id);
228
+ if (p.open) { delete loading[id]; loadPanel(id, loaders[id].bodyId, loaders[id].files); }
229
+ });
230
+ }
167
231
  }
168
232
 
169
233
  // ⚠️ DE-ROT (F38): on a --debate run the FINAL tally.json is rebuilt from the debate's
@@ -183,9 +247,14 @@
183
247
  var rv = ((A.state.debate && A.state.debate.revotes) || []).find(function (r) {
184
248
  return r.judge === judgePair.model && r.id === findingId;
185
249
  });
250
+ // ⚠️ Task 18 fix-wave (RN-1, review finding 1): this branch used to recompute the name via
251
+ // bare sanitizeName(judgePair.model), independently of the (already-fixed) judge branch
252
+ // right below it — for a colliding pair, drilling a re-vote on the SECOND model resolved
253
+ // to the bare name and cross-matched the FIRST model's genuine revote section. Both arms
254
+ // of this ternary now go through the same disambiguation-aware helper.
186
255
  var artifactName = rv
187
- ? 'revote-' + sanitizeName(judgePair.model) + '.md'
188
- : 'judge-' + sanitizeName(judgePair.model) + '.md';
256
+ ? resolveArtifactName(judgePair.model, 'revote')
257
+ : resolveArtifactName(judgePair.model, 'judge');
189
258
  var section = A.$('judges-body').querySelector('[data-artifact="' + artifactName + '"]');
190
259
  // A genuinely absent artifact is not an error here — the panel renders its own
191
260
  // "<file> not written yet" empty state (spec §9, last row).
@@ -93,7 +93,13 @@
93
93
  ]),
94
94
  el('div', { className: 'run-row-sub' }, [
95
95
  relTime(row.startedAt),
96
- String(row.bench.length) + ' seats',
96
+ // ⚠️ v4.4.1 RN-12: `String(row.bench.length)` was unguarded. scanCouncilRuns is written
97
+ // to "never throw on bad input" and degrade to a row instead — but this painter draws
98
+ // EVERY row, so one row arriving without a `bench` array threw a TypeError that blanked
99
+ // the entire run list, killing the data layer's degrade-never-throw guarantee one layer
100
+ // up. (scanCouncilRuns' own error rows take the early return above; this guards the
101
+ // contract with any OTHER row source, which is what the guarantee is actually worth.)
102
+ (Array.isArray(row.bench) ? row.bench.length : 0) + ' seats',
97
103
  'chair ' + displayModel(row.chair, blindOn, labelOf),
98
104
  row.overallVerdict || '',
99
105
  row.costDisplay || '',
@@ -199,8 +205,18 @@
199
205
  cells.forEach(function (c, i) {
200
206
  var td = row.children[i];
201
207
  if (td && td.textContent !== c) { td.textContent = c; }
208
+ if (td) { td.className = i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : ''); }
202
209
  });
203
210
  });
211
+ // RN-11 (v4.5): the keyed update added and removed rows but never MOVED
212
+ // them, so table order was frozen at first render — wrong the moment a
213
+ // repair solo or new wave changes the composed doc's leg order mid-run.
214
+ seats.forEach(function (seat, i) {
215
+ var key = String(seat.id || seat.model);
216
+ var current = tbody.children[i];
217
+ var target = Array.prototype.slice.call(tbody.children).find(function (r) { return r.dataset.key === key; });
218
+ if (target && target !== current) { tbody.insertBefore(target, current || null); }
219
+ });
204
220
  Array.prototype.slice.call(tbody.children).forEach(function (row) {
205
221
  if (!seen[row.dataset.key]) { row.remove(); }
206
222
  });
@@ -20,6 +20,13 @@
20
20
  function doFold() {
21
21
  var A = window.AmicusApp;
22
22
  var btn = A.$('fold-btn');
23
+ // ⚠️ v4.4.1 LC-8: this chain had NO rejection handler. workspace:fold's IPC handler catches
24
+ // its own errors and answers {ok:false, error}, so a REJECTION means the channel itself failed
25
+ // — and the user saw the Fold button sitting in its pre-click state with no explanation, while
26
+ // the renderer logged an unhandled rejection (which jest-circus fails the running test on).
27
+ // Two-argument .then(onFulfilled, onRejected), NOT .then().catch() — see the long note in
28
+ // startLiveLoop below: with a trailing .catch, a THROW inside onFulfilled would be routed here
29
+ // too and misreported as a channel failure.
23
30
  A.invoke('workspace:fold', A.state.runId).then(function (res) {
24
31
  if (res.ok) {
25
32
  btn.textContent = 'Folded ✓';
@@ -28,6 +35,9 @@
28
35
  } else {
29
36
  btn.title = res.error || 'fold failed';
30
37
  }
38
+ }, function (err) {
39
+ console.error('workspace fold: workspace:fold failed', err);
40
+ btn.title = 'fold failed — the workspace channel is unavailable' + (err && err.message ? ': ' + err.message : '');
31
41
  });
32
42
  }
33
43
 
@@ -180,7 +190,23 @@
180
190
  R.renderBanner(A.$('banner'),
181
191
  'No leg activity' + (mins ? ' for ' + mins + 'm' : '') + ' — the run may be dead. Abort to reclaim it; everything on disk stays browsable.',
182
192
  'live');
183
- A.$('abort-btn').hidden = false; // the remedy ships beside the diagnosis (Task 16 wires it)
193
+ // ⚠️ v4.4.1 RN-6 ARBITRATED, and deliberately NOT fixed as the backlog proposed. RN-6
194
+ // asked for a matching `hidden = true` in the clearing arm below, believing this line is
195
+ // what puts the Abort button on screen. It is not: workspace-app.js's renderDetail already
196
+ // sets `$('abort-btn').hidden = isTerminal` on every run-open (:128), and startLiveLoop only
197
+ // runs on a non-terminal run — so the button is ALREADY visible for the whole life of a live
198
+ // run, by design, and this assignment is a no-op on every path that reaches it. The proposed
199
+ // clearing-arm write would HIDE the button the moment a momentary stall recovered, leaving a
200
+ // healthy, still-running council with no way to abort it: the inverse defect, and worse.
201
+ // Full reasoning + the regression pin: "the Abort button survives a stall -> recover cycle"
202
+ // in tests/workspace/live-loop.test.js.
203
+ //
204
+ // v4.4.1 M4: this line is NOT "the remedy" (that framing, from Task 16, is what this note
205
+ // corrects) — it is redundant on every path except one: a run that dies WHILE stalled,
206
+ // whose terminal openRun() refresh then rejects. On THAT path it leaves Abort visible on a
207
+ // run that is already dead — a false affordance, not a working safety net. Kept anyway
208
+ // because it is harmless: aborting a dead run is a no-op.
209
+ A.$('abort-btn').hidden = false;
184
210
  } else if (A.$('banner').classList.contains('live')) {
185
211
  // Restores whatever the DURABLE banner actually says (nothing, a schemaVersion mismatch,
186
212
  // run.error, …) instead of just blanking it — a live-layer banner (info-unavailable or
@@ -216,7 +242,27 @@
216
242
  return;
217
243
  }
218
244
  stopLiveLoop();
219
- A.openRun(A.state.runId); // re-read: status flips to aborted, grey chip, no live poll
245
+ // ⚠️ v4.4.1 LC-8: the re-read was fire-and-forget (wsgate01 C5) inconsistent with the
246
+ // live loop's terminal branch above, which was explicitly given this same handler for this
247
+ // same reason. If the re-read fails, the status chip never leaves 'running' and the UI shows
248
+ // a live run that isn't. `.catch` (not the two-argument form) is EXACTLY equivalent here and
249
+ // matches the sibling at the terminal branch above: there is no onFulfilled for a trailing
250
+ // .catch to swallow a throw out of, which is the only thing the two-argument form buys.
251
+ A.openRun(A.state.runId).catch(function (err) { // re-read: status flips to aborted, grey chip, no live poll
252
+ console.error('workspace abort: post-abort get-run refresh failed', err);
253
+ window.AmicusRender.renderBanner(A.$('banner'),
254
+ 'Abort succeeded, but refreshing the run failed — reopen the run to see its final state.', '');
255
+ });
256
+ }, function (err) {
257
+ // ⚠️ v4.4.1 LC-8 (same handler, same class): a REJECTED invoke() left `btn.disabled = true`
258
+ // and the dialog open forever — the confirm button wedged with no way back — plus an
259
+ // unhandled rejection. workspace:abort-run's IPC handler answers {ok:false} on its own
260
+ // errors, so reaching here means the channel failed, not the abort.
261
+ console.error('workspace abort: workspace:abort-run failed', err);
262
+ btn.disabled = false;
263
+ $('dialog-abort').hidden = true;
264
+ window.AmicusRender.renderBanner(A.$('banner'),
265
+ 'Abort failed: the workspace channel is unavailable' + (err && err.message ? ' — ' + err.message : ''), '');
220
266
  });
221
267
  });
222
268