amicus 4.4.0 → 4.4.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 (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +3 -1
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/main.js +4 -1
  21. package/electron/setup-ui-aliases.js +6 -6
  22. package/electron/workspace-ui/live-model.js +12 -1
  23. package/electron/workspace-ui/md-lite.js +52 -8
  24. package/electron/workspace-ui/workspace-matrix.js +46 -9
  25. package/electron/workspace-ui/workspace-panels.js +14 -3
  26. package/electron/workspace-ui/workspace-render.js +7 -1
  27. package/electron/workspace-ui/workspace-verbs.js +48 -2
  28. package/package.json +8 -3
  29. package/schemas/council-run.schema.json +20 -0
  30. package/schemas/progress.schema.json +12 -0
  31. package/schemas/spend.schema.json +52 -4
  32. package/src/cli-handlers-spend.js +20 -2
  33. package/src/cli-handlers-watch.js +11 -0
  34. package/src/cli.js +4 -2
  35. package/src/council/briefings-debate.js +27 -7
  36. package/src/council/briefings-stage2.js +155 -25
  37. package/src/council/briefings.js +24 -1
  38. package/src/council/findings.js +236 -9
  39. package/src/council/parse-stage2.js +10 -2
  40. package/src/council/report.js +19 -8
  41. package/src/council/run-assemble.js +42 -1
  42. package/src/council/run-budget.js +64 -11
  43. package/src/council/run-chair.js +4 -1
  44. package/src/council/run-debate.js +4 -2
  45. package/src/council/run-finalize.js +102 -0
  46. package/src/council/run-launch.js +29 -1
  47. package/src/council/run-server.js +248 -0
  48. package/src/council/run-stage2.js +118 -0
  49. package/src/council/run-stages.js +132 -111
  50. package/src/council/run-state.js +23 -1
  51. package/src/council/run.js +44 -46
  52. package/src/council/tally.js +10 -0
  53. package/src/headless.js +175 -6
  54. package/src/observe/council-legs.js +60 -3
  55. package/src/observe/live-doc.js +18 -1
  56. package/src/observe/watch-render.js +4 -1
  57. package/src/sidecar/child-sessions.js +1 -2
  58. package/src/sidecar/fanout-leg-fallback.js +69 -21
  59. package/src/sidecar/fanout-leg.js +6 -0
  60. package/src/sidecar/fanout-signals.js +61 -0
  61. package/src/sidecar/fanout-wave-io.js +75 -0
  62. package/src/sidecar/fanout.js +61 -70
  63. package/src/sidecar/progress-fields.js +26 -4
  64. package/src/sidecar/progress.js +8 -1
  65. package/src/sidecar/session-utils.js +23 -14
  66. package/src/spend-query.js +17 -5
  67. package/src/utils/lifecycle.js +37 -1
  68. package/src/utils/path-fence.js +39 -1
  69. package/src/utils/pricing.js +26 -10
  70. package/src/utils/server-setup.js +79 -1
  71. package/src/utils/spend-ledger.js +24 -3
  72. package/src/workspace/artifact-guard.js +22 -1
  73. package/src/workspace/fold-format.js +33 -4
  74. package/src/workspace/live-normalize.js +28 -15
  75. package/src/workspace/run-detail.js +7 -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');
@@ -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
 
@@ -134,19 +134,30 @@
134
134
  // than new).
135
135
  var artifacts = A.state.detail.artifacts || {};
136
136
  function present(name) { return !!(artifacts[name] && artifacts[name].present); }
137
+ // ⚠️ v4.4.1 RN-9: these two titles used to hand-roll `A.state.blind && label ? label : m`
138
+ // inline. Both now go through AmicusRender.display() — the single blind-flip definition the
139
+ // re-vote title below already used — so the next blind-mode ruling lands in one place instead
140
+ // of being re-applied by hand in every file that happens to render an identity.
137
141
  loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
138
142
  return bench.map(function (m) {
139
143
  var label = A.state.labelByModel[m];
140
- return { name: 'review-' + sanitizeName(m) + '.md', title: (A.state.blind && label ? label : m) };
144
+ return { name: 'review-' + sanitizeName(m) + '.md', title: window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
141
145
  }).filter(function (f) { return present(f.name); });
142
146
  } };
143
147
  loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
144
- return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }];
148
+ // ⚠️ v4.4.1 RN-4: the presence filter is NOT optional here either. Without it, a run whose
149
+ // Stage 2 never ran (a one-seat bench, an abort before the cross-review, a cost ceiling)
150
+ // requested a file the manifest already knows is absent and rendered readRunArtifact's raw
151
+ // error string in the panel — "absolute host path and all", per this file's own round-2
152
+ // note above `present()`. reviews-panel and judges-panel have always filtered; this was the
153
+ // odd one out.
154
+ return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }]
155
+ .filter(function (f) { return present(f.name); });
145
156
  } };
146
157
  loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
147
158
  var files = bench.map(function (m) {
148
159
  var label = A.state.labelByModel[m];
149
- return { name: 'judge-' + sanitizeName(m) + '.md', title: 'Judge ' + (A.state.blind && label ? label : m) };
160
+ return { name: 'judge-' + sanitizeName(m) + '.md', title: 'Judge ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
150
161
  });
151
162
  if (debated) {
152
163
  // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
@@ -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 || '',
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.4.0",
3
+ "version": "4.4.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": [
@@ -43,6 +43,7 @@
43
43
  "commands/",
44
44
  ".claude-plugin/",
45
45
  "CHANGELOG.md",
46
+ "docs/*.md",
46
47
  "scripts/postinstall.js",
47
48
  "scripts/setup-hooks.js"
48
49
  ],
@@ -50,11 +51,11 @@
50
51
  "start": "node --experimental-top-level-await --experimental-vm-modules bin/amicus.js",
51
52
  "test": "jest",
52
53
  "test:integration": "node scripts/run-integration-keyless.js",
53
- "test:integration:live": "jest --testPathIgnorePatterns='worktrees' --testMatch='**/tests/**/*.integration.test.js'",
54
+ "test:integration:live": "jest --runInBand --testPathIgnorePatterns='worktrees' --testMatch='**/tests/**/*.integration.test.js'",
54
55
  "test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='worktrees' && node scripts/mark-test-passed.js",
55
56
  "test:e2e:mcp": "jest tests/mcp-repomix-e2e.integration.test.js --testTimeout=180000 --forceExit",
56
57
  "posttest": "node scripts/mark-test-passed.js",
57
- "lint": "eslint src/",
58
+ "lint": "eslint src/ electron/",
58
59
  "postinstall": "node scripts/postinstall.js",
59
60
  "test:thinking": "node scripts/benchmark-thinking.js",
60
61
  "test:thinking:quick": "MODELS=gemini node scripts/benchmark-thinking.js",
@@ -88,6 +89,7 @@
88
89
  "ajv": "^8.20.0",
89
90
  "chrome-remote-interface": "^0.33.3",
90
91
  "eslint": "^8.0.0",
92
+ "espree": "^9.6.1",
91
93
  "jest": "^29.0.0",
92
94
  "lint-staged": "^16.3.2",
93
95
  "puppeteer": "^24.36.0",
@@ -100,6 +102,9 @@
100
102
  "lint-staged": {
101
103
  "src/**/*.js": [
102
104
  "eslint --fix"
105
+ ],
106
+ "electron/**/*.js": [
107
+ "eslint --fix"
103
108
  ]
104
109
  }
105
110
  }
@@ -48,6 +48,26 @@
48
48
  }
49
49
  }
50
50
  },
51
+ "sharedServer": {
52
+ "description": "The run acquired its single shared OpenCode server and threaded it through every wave (v4.4.1 Task 0.5). Present only on a successful acquisition; mutually exclusive with sharedServerUnavailable. `goPid` is the shared server's process id — no wave writes a goPid into its own metadata.json while riding an injected server, so this field is the POSITIVE, direct evidence that one server served the run, replacing the goPid-absence inference.",
53
+ "type": "object",
54
+ "required": ["acquired"],
55
+ "properties": {
56
+ "acquired": { "const": true },
57
+ "at": { "type": "string" },
58
+ "goPid": { "type": ["number", "null"] },
59
+ "models": { "type": "number" }
60
+ }
61
+ },
62
+ "sharedServerUnavailable": {
63
+ "description": "The run could not start its single shared OpenCode server and fell back to one server per wave (v4.4.1 Task 0.5). Present only when the acquisition failed. The run CONTINUES — this is never fatal — but the per-wave configuration is the one that races on OpenCode's SQLite, so results may be degraded.",
64
+ "type": "object",
65
+ "required": ["error"],
66
+ "properties": {
67
+ "error": { "type": "string" },
68
+ "at": { "type": "string" }
69
+ }
70
+ },
51
71
  "debate": {
52
72
  "type": "object",
53
73
  "properties": {
@@ -12,6 +12,18 @@
12
12
  "updatedAt": { "type": "string", "format": "date-time" },
13
13
  "messagesReceived": { "type": "number" },
14
14
  "latestTool": { "type": "string" },
15
+ "toolSettleTimedOut": {
16
+ "description": "v4.4 B4. The leg was completed while at least one tool call was still non-terminal, after AMICUS_TOOL_SETTLE_GRACE_MS elapsed. Its partial output was kept and it was NOT failed, but its reported cost is a floor. Present only when true.",
17
+ "const": true
18
+ },
19
+ "unsettledToolCalls": {
20
+ "description": "How many tool calls were still live when the settle grace was exceeded. A count here (progress.json is a compact snapshot); the full list rides the runHeadless result. Present only alongside toolSettleTimedOut.",
21
+ "type": "number"
22
+ },
23
+ "toolSettleAborted": {
24
+ "description": "v4.4.1 LC-2. Whether the leg's OpenCode session was successfully aborted at the settle ceiling so it would stop billing. `false` means the abort was attempted and failed — the session may still be billing. Present only alongside toolSettleTimedOut.",
25
+ "type": "boolean"
26
+ },
15
27
  "usage": {
16
28
  "type": "object",
17
29
  "properties": {
@@ -9,8 +9,28 @@
9
9
  "schemaVersion": { "const": 2 },
10
10
  "type": { "const": "spend" },
11
11
  "windowDays": { "type": ["number", "null"] },
12
- "total": { "type": "object" },
13
- "byModel": { "type": "array", "items": { "type": "object" } },
12
+ "total": {
13
+ "type": "object",
14
+ "properties": {
15
+ "amount": { "type": "number" },
16
+ "runs": { "type": "number" },
17
+ "unpricedRows": { "$ref": "#/$defs/unpricedRows" },
18
+ "unattributedSubtreeRows": { "$ref": "#/$defs/unattributedSubtreeRows" }
19
+ }
20
+ },
21
+ "byModel": {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "object",
25
+ "properties": {
26
+ "model": { "type": "string" },
27
+ "amount": { "type": "number" },
28
+ "runs": { "type": "number" },
29
+ "unpricedRows": { "$ref": "#/$defs/unpricedRows" },
30
+ "unattributedSubtreeRows": { "$ref": "#/$defs/unattributedSubtreeRows" }
31
+ }
32
+ }
33
+ },
14
34
  "credit": { "type": ["object", "null"] },
15
35
  "filters": { "type": "object" },
16
36
  "groupBy": { "enum": ["model", "wave", "council", "project", "op", "day"] },
@@ -22,7 +42,9 @@
22
42
  "properties": {
23
43
  "key": { "type": "string" },
24
44
  "amount": { "type": "number" },
25
- "runs": { "type": "number" }
45
+ "runs": { "type": "number" },
46
+ "unpricedRows": { "$ref": "#/$defs/unpricedRows" },
47
+ "unattributedSubtreeRows": { "$ref": "#/$defs/unattributedSubtreeRows" }
26
48
  }
27
49
  }
28
50
  },
@@ -32,10 +54,36 @@
32
54
  "properties": {
33
55
  "amount": { "type": "number" },
34
56
  "runs": { "type": "number" },
57
+ "unpricedRows": { "$ref": "#/$defs/unpricedRows" },
58
+ "unattributedSubtreeRows": { "$ref": "#/$defs/unattributedSubtreeRows" },
35
59
  "byStatus": { "type": "object" }
36
60
  }
37
61
  },
38
- "rows": { "type": "array" },
62
+ "rows": {
63
+ "type": "array",
64
+ "items": {
65
+ "type": "object",
66
+ "description": "A raw spend-ledger.jsonl row (internal v1 shape, echoed verbatim when --rows is passed). Always an object: readSpendRows drops any ledger line that does not parse as one, scalars and arrays included. Only the fields this document makes claims about are pinned beyond that.",
67
+ "properties": {
68
+ "subtreeUnknown": {
69
+ "type": "boolean",
70
+ "description": "v4.4.1. Present (and always true) only when this leg's own cost resolved but it spawned a child session whose spend could not be determined — the row is PRICED yet its cost is a floor. OMITTED entirely otherwise, so an ordinary row is byte-identical to a pre-4.4.1 one."
71
+ }
72
+ }
73
+ }
74
+ },
39
75
  "rowsTruncated": { "type": "boolean" }
76
+ },
77
+ "$defs": {
78
+ "unpricedRows": {
79
+ "type": "integer",
80
+ "minimum": 0,
81
+ "description": "Rows that contributed NOTHING to `amount` (no numeric cost). The bucket's figure omits them entirely: real spend is at least this much."
82
+ },
83
+ "unattributedSubtreeRows": {
84
+ "type": "integer",
85
+ "minimum": 0,
86
+ "description": "v4.4.1 CA-2. Rows that ARE priced and DO contribute to `amount`, but whose leg spawned a subagent whose child-session spend could not be determined — the second, independent reason the figure is a floor. A row can be counted in both this and `unpricedRows`; the two are never substitutes."
87
+ }
40
88
  }
41
89
  }
@@ -50,15 +50,22 @@ function parseSinceDays(since) {
50
50
  * the arithmetic actually saw. It exists so the renderers can say "$X plus N
51
51
  * unknown" instead of coercing null→0 and printing a measured-looking $0.0000
52
52
  * (diagnosis §8, final paragraph).
53
+ *
54
+ * v4.4.1 CA-2: `unattributedSubtreeRows` is its sibling for the OTHER way the
55
+ * figure understates. Such a row is fully priced — it is in `amount`, it is in
56
+ * the `r` bucket, `unpricedRows` never sees it — but the leg spawned a child
57
+ * session whose spend the walk could not determine, which is exactly what makes
58
+ * `council run` report `costExact: false` about the same dollars. Without this
59
+ * counter `amicus spend` disagreed with `council run` and looked complete.
53
60
  * @param {Array<object>} rows
54
61
  */
55
62
  function aggregateSpend(rows) {
56
- const total = { amount: 0, tokens: emptyTokens(), runs: rows.length, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } };
63
+ const total = { amount: 0, tokens: emptyTokens(), runs: rows.length, unpricedRows: 0, unattributedSubtreeRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } };
57
64
  const byModelMap = new Map();
58
65
  for (const r of rows) {
59
66
  const model = r.model || 'unknown';
60
67
  if (!byModelMap.has(model)) {
61
- byModelMap.set(model, { model, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } });
68
+ byModelMap.set(model, { model, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } });
62
69
  }
63
70
  const bucket = byModelMap.get(model);
64
71
  bucket.runs += 1;
@@ -68,6 +75,8 @@ function aggregateSpend(rows) {
68
75
  const priced = typeof cost.amount === 'number';
69
76
  const amount = priced ? cost.amount : 0;
70
77
  if (!priced) { bucket.unpricedRows += 1; total.unpricedRows += 1; }
78
+ // Beside, never instead of: a row can be both unpriced and subtree-unknown.
79
+ if (r.subtreeUnknown) { bucket.unattributedSubtreeRows += 1; total.unattributedSubtreeRows += 1; }
71
80
  bucket.amount += amount;
72
81
  total.amount += amount;
73
82
  // Any source string outside {reported,estimated} buckets as unknown —
@@ -178,6 +187,15 @@ function renderHuman({ total, byModel, windowDays, credit, wasted }) {
178
187
  out += `${total.unpricedRows} unpriced row(s) — cost unknown and NOT in the total; `
179
188
  + 'real spend is at least this much.\n';
180
189
  }
190
+ // v4.4.1 CA-2: a DIFFERENT statement from the line above, and deliberately
191
+ // worded as one. "We could not see this leg at all" (unpriced) vs "we saw this
192
+ // leg, and its own cost IS in the total, but not what it spawned" — the second
193
+ // is why `council run` reports costExact:false on a row that looks complete
194
+ // here. Both lines can appear at once; a row can be in both counts.
195
+ if (total.unattributedSubtreeRows > 0) {
196
+ out += `${total.unattributedSubtreeRows} row(s) spawned a subagent whose CHILD session `
197
+ + 'spend could NOT be determined — real spend is HIGHER than this total.\n';
198
+ }
181
199
  if (wasted && wasted.runs > 0) {
182
200
  out += `Wasted (failed runs): ${formatCost({ amount: wasted.amount, source: 'mixed' })} across ${wasted.runs} rows — see amicus spend --failed\n`;
183
201
  }
@@ -84,6 +84,17 @@ async function handleWatch(args) {
84
84
  // at the wrong directory when a run was launched elsewhere.
85
85
  const project = args.project || args.cwd || process.cwd();
86
86
  const runId = args._[1] ? String(args._[1]) : '';
87
+ // ⚠️ v4.4.1 DOC-3: validate HERE, not by moving the branch. The branch's
88
+ // POSITION above the `id is required` gate is load-bearing (bare `--ui`
89
+ // opens the run-list landing and must keep working), so the runId check is
90
+ // pushed inside it and made conditional on a runId actually being supplied.
91
+ // Without this, `amicus watch <typo> --ui` skipped validateTaskId entirely
92
+ // and surfaced whatever getRunDetail produced — a vaguer error than the
93
+ // identical typo gets on the terminal path.
94
+ if (runId) {
95
+ const uiCheck = validateTaskId(runId);
96
+ if (!uiCheck.valid) { process.stderr.write(`${uiCheck.error}\n`); return 1; }
97
+ }
87
98
  const { launchWorkspaceWindow } = require('./sidecar/workspace-window');
88
99
  const res = await launchWorkspaceWindow({ project, runId });
89
100
  if (res.error) { process.stderr.write(`${res.error}\n`); }