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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -0
- package/README.md +3 -1
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +14 -3
- package/electron/workspace-ui/workspace-render.js +7 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run.schema.json +20 -0
- package/schemas/progress.schema.json +12 -0
- package/schemas/spend.schema.json +52 -4
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +132 -111
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +44 -46
- package/src/council/tally.js +10 -0
- package/src/headless.js +175 -6
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +61 -70
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/spend-query.js +17 -5
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +22 -1
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
55
|
-
const arrowIcon =
|
|
56
|
-
const modelIcon =
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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:
|
|
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
|
-
|
|
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 ' +
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
|
13
|
-
|
|
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": {
|
|
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`); }
|