amicus 4.9.0 → 4.9.2
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 +124 -0
- package/README.md +1 -1
- package/docs/usage.md +1 -1
- package/electron/ipc-setup.js +5 -3
- package/electron/main.js +19 -5
- package/electron/setup-ui.js +28 -31
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +10 -0
- package/src/council/run-retry-window.js +62 -0
- package/src/council/run-retry.js +7 -10
- package/src/council/run-stage2.js +47 -3
- package/src/council/tally.js +12 -0
- package/src/council/verdict-seats-reviewed.js +60 -0
- package/src/council/verdict.js +23 -0
- package/src/headless.js +90 -9
- package/src/sidecar/fanout-leg-fallback.js +2 -1
- package/src/sidecar/models-render.js +71 -0
- package/src/sidecar/models.js +12 -45
- package/src/sidecar/reopen-spend.js +2 -1
- package/src/sidecar/setup.js +13 -4
- package/src/sidecar/start.js +2 -1
- package/src/utils/alias-audit.js +10 -3
- package/src/utils/alias-shadow.js +2 -2
- package/src/utils/curated-models.js +8 -6
- package/src/utils/degrade.js +8 -0
- package/src/utils/gateway-router.js +11 -1
- package/src/utils/model-canonicalization.js +55 -6
- package/src/utils/model-catalog.js +26 -8
- package/src/utils/model-fetcher.js +69 -16
- package/src/utils/model-shortlist.js +5 -2
- package/src/utils/provider-default-picker.js +6 -3
- package/src/utils/quick-picks.js +45 -7
- package/src/utils/result-schema.js +7 -1
- package/src/utils/session-status.js +73 -0
- package/src/utils/ttft.js +17 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.2",
|
|
4
4
|
"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.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christian Wagner"
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,130 @@
|
|
|
3
3
|
All notable changes to Amicus are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
+
## [4.9.2] - 2026-08-27
|
|
7
|
+
|
|
8
|
+
*The instrument existed; nothing could read it.*
|
|
9
|
+
|
|
10
|
+
Issue #202 deferred its retry-policy decision to evidence: "v4.9 W13 records per-leg
|
|
11
|
+
time-to-first-token in `runStats`, so the next rev can derive this from observation instead of
|
|
12
|
+
argument." That probe had never reported a value into any artifact CI uploads — `tally.js`'s
|
|
13
|
+
hand-maintained allowlist stripped it one hop before `tally.json`. Reading it changed the
|
|
14
|
+
diagnosis: first tokens on the CI egress are a continuous heavy tail (8.0 s to 384.2 s, no gap),
|
|
15
|
+
not an upstream that accepts and never serves. This release fixes the instrument, then the
|
|
16
|
+
kill switches that were set inside that tail.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **`ttftMs` survives the runStats re-projection (#202).** `tally.js :: tally` re-projects every
|
|
21
|
+
row through a hand-maintained allowlist that never named the field, and `verdict.js` copies that
|
|
22
|
+
array verbatim — so the W13 probe emitted correctly into `tally-input.json` and was destroyed
|
|
23
|
+
before `tally.json` and `verdict.json`, the only artifacts CI uploads. MEASURED, run
|
|
24
|
+
33030485388: 11 of 12 rows carried it going in, 0 of 12 coming out. `tally.js` becomes the fifth
|
|
25
|
+
emit gate and the fourth importer of the shared `isMeasuredTtft` predicate. A drift pin now fails
|
|
26
|
+
for ANY future `buildRunStatsEntry` key the allowlist is not taught to carry.
|
|
27
|
+
- **A zero-output leg now names its cause (#202).** `getSessionStatus` was called only inside
|
|
28
|
+
`if (mirror.output.length > 0)` — a gate a leg that produced nothing never satisfies — so the one
|
|
29
|
+
leg needing diagnosis was the only one that never asked. A bounded, non-throwing read now runs at
|
|
30
|
+
the two backstop firing sites (a living leg makes no extra call) and appends `busy` /`idle`/
|
|
31
|
+
`retry` with the upstream message. `busy` means provider-side, `idle` means engine-side, `retry`
|
|
32
|
+
names the cause; none is suppressed. Untrusted provider text is sanitized.
|
|
33
|
+
- **Dead Stage-2 judge legs are announced (#202).** A dead judge leg still binds to its seat, so it
|
|
34
|
+
was neither `orphan` nor `unbound` and Stage 2 had no third case: it fell through into
|
|
35
|
+
`judgeResults` unremarked. Run 32956900910 shipped a four-column adjudication matrix that two of
|
|
36
|
+
its four judges never voted in, with no degrade recorded. New `stage2-judge` channel.
|
|
37
|
+
- **B53 no longer kills healthy, billing legs.** `TOOL_CALL_STALL_MS` was 180 s, condemned by this
|
|
38
|
+
repo's own measurement — a real 190.6 s `task` call recorded in `headless.js`, taken on a
|
|
39
|
+
developer machine. That measurement had corrected its neighbour (the settle deferral) and left
|
|
40
|
+
its own subject alone. Now 300 s, with CI overriding to 480 s.
|
|
41
|
+
- **The Stage-1 retry backstop no longer ties with the leg timeout.** `min(2 * backstop, legCap)`
|
|
42
|
+
made the deadlines equal whenever `2 * backstop >= legCap`; the backstop won only by the poll
|
|
43
|
+
loop's ordering. It now clamps strictly below, so a retry death keeps its named diagnosis
|
|
44
|
+
instead of degrading to a generic `timeout`.
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **`verdict.json` publishes `seatsReviewed {reviewed, of}`.** `deriveSeatLoss` returns null when
|
|
49
|
+
no `--critic` was requested, and CI requests none, so seat loss was structurally absent from
|
|
50
|
+
every CI verdict while a two-seat bench published a four-model street-cred table. Derived from
|
|
51
|
+
`runStats`, counting the bench roles `buildSeats` mints (`seat`, `critic`, `lens:<slug>`).
|
|
52
|
+
Surfaced in the check-run title and the sticky comment footer.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **A dead Stage-2 judge now degrades the run, so it exits 2.** Previously a half-adjudicated
|
|
57
|
+
verdict could exit 0. This changes CI signal: runs that passed before will now report degraded
|
|
58
|
+
when a judge dies.
|
|
59
|
+
- **CI council caps.** Per-leg `--timeout` 10 -> 16 min and `timeout-minutes` 45 -> 75, to give the
|
|
60
|
+
tool-stall detector real reach (120 s -> 480 s of a leg). The job cap covers a worst case of
|
|
61
|
+
four leg caps; that figure is a floor, since Stage-2 repairs are serial.
|
|
62
|
+
|
|
63
|
+
## [4.9.1] - 2026-08-27
|
|
64
|
+
|
|
65
|
+
*A silent provider failure, and the unservable model ids it produced.*
|
|
66
|
+
|
|
67
|
+
A session on `deepseek` failed with `you passed deepseek-v4-flash-0731`. Tracing that one error
|
|
68
|
+
found a chain: a provider fetch failed silently, the empty namespace it left behind was
|
|
69
|
+
indistinguishable from "never fetched", and that ambiguity licensed the wizard to synthesise and
|
|
70
|
+
persist a direct model id nothing serves.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **Per-provider catalog fetch failures are reported instead of collapsing to `[]` (#209).**
|
|
75
|
+
`fetchAllModels()` returned a flat row array, so a provider whose fetch was REJECTED could not be
|
|
76
|
+
told apart from one that legitimately serves no models — all four failure modes (non-200, timeout,
|
|
77
|
+
network error, parse error) resolved to a bare `[]`, and the existing reporting fired only when
|
|
78
|
+
*every* provider returned nothing. A 401ing key silently zeroed that vendor's namespace and every
|
|
79
|
+
picker then offered gateway-only routes with no indication why. `fetchAllModelsDetailed()` now
|
|
80
|
+
carries `{rows, failures}`, the cache persists `providerFailures` alongside the rows they
|
|
81
|
+
describe, and `amicus models --check` prints
|
|
82
|
+
`PROVIDER FETCH FAILED: <provider> (HTTP 401)` (`--json` carries the array).
|
|
83
|
+
`fetchAllModels()` keeps its signature as a rows-only wrapper.
|
|
84
|
+
- **A rejected namespace no longer yields a fabricated direct model id (#208).**
|
|
85
|
+
`classifyModel` returns `'unknown'` for an empty namespace because it could not distinguish
|
|
86
|
+
"never fetched" from "fetch rejected", and that licensed `directFormIfSafe` to strip the
|
|
87
|
+
`openrouter/` prefix and produce an id the direct API does not serve. Optimism is now suppressed
|
|
88
|
+
when *that vendor's* namespace fetch was rejected, and preserved when it was simply never
|
|
89
|
+
attempted. Threaded through the picker, the shortlist, both Electron wizard entry points and the
|
|
90
|
+
CLI setup path — the picker rebuilds its own `catalogInfo`, so without threading the guard was
|
|
91
|
+
live in unit tests and dead in production.
|
|
92
|
+
- **The persistence path requires catalog evidence (#214).**
|
|
93
|
+
`toStorableRoute` — whose result is written straight into `config.aliases` and used to seed fresh
|
|
94
|
+
configs — decided direct-vs-gateway with no catalog evidence at all. It now routes through
|
|
95
|
+
`directFormIfSafe`, and `toLiveSeedAliases` stops discarding evidence it was already handed.
|
|
96
|
+
- **Alias drift is computed against the same evidence the writer uses.**
|
|
97
|
+
`findDriftedStoredAliases` resolved its "current" value without `providerFailures`, so a rejected
|
|
98
|
+
namespace produced false drift whose suggested repair would have written back the very id #208
|
|
99
|
+
removed.
|
|
100
|
+
- **Guards no longer depend on an optional argument.** `directFormIfSafe`/`directFormIfProven`
|
|
101
|
+
keyed both the `DIVERGENT_VENDORS` and namespace-rejection checks on a caller-supplied `vendor`
|
|
102
|
+
that the API marks optional, while the catalog check derived its own — so a caller using the
|
|
103
|
+
documented shape silently lost two of three guards. The vendor is now derived from the id when
|
|
104
|
+
omitted.
|
|
105
|
+
- **The setup wizard keeps direct-first routing when the catalog is unavailable.** With no catalog,
|
|
106
|
+
quick picks fell back to raw `openrouter/…` routes, which Amicus treats as an explicit
|
|
107
|
+
force-OpenRouter literal — an offline setup would have pinned the user to the gateway
|
|
108
|
+
permanently.
|
|
109
|
+
- **`pre-commit` no longer fails on a clone that has never stashed.** The hook gated its
|
|
110
|
+
lint-staged workaround on being inside a worktree; the real trigger is a missing `refs/stash`,
|
|
111
|
+
which is true of any fresh clone whose first commit precedes its first `git stash`.
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
|
|
115
|
+
- **`toCanonicalDefault` is renamed `stripGatewayPrefix`** (`src/utils/curated-models.js`). The old
|
|
116
|
+
name read as the correct answer and three separate callers took it at its word and persisted ids
|
|
117
|
+
the direct API may not serve. It cannot be made evidence-taking — it *produces* the candidate
|
|
118
|
+
`classifyModel` checks — so the name now says what it is. Callers deriving an id that will be
|
|
119
|
+
called or stored must use `directFormIfSafe`/`directFormIfProven`. Internal utility, not a
|
|
120
|
+
documented API surface, but importers reaching into `src/utils/` will need the new name.
|
|
121
|
+
- **Route canonicalisation for the setup wizard is decided in the main process.** The renderer
|
|
122
|
+
carried a hand-copy of `toCanonicalDefault` that had neither of the real primitive's guards; it is
|
|
123
|
+
deleted, and the safe form now ships to the page as data. Routing policy no longer reaches the
|
|
124
|
+
renderer at all.
|
|
125
|
+
- **`gatewayOf(id)`** replaces three verbatim copies of the gateway-classification one-liner.
|
|
126
|
+
- An ESLint rule now bans hand-rolled `openrouter/` prefix stripping outside an audited allowlist.
|
|
127
|
+
- CI council bench: `qwen` moves from `qwen3.8-max` to `qwen3.8-27b` — same 1M context, ~4.8x
|
|
128
|
+
cheaper input. Bench-only; the shipped alias table is unchanged.
|
|
129
|
+
|
|
6
130
|
## [4.9.0] - 2026-08-26
|
|
7
131
|
|
|
8
132
|
*The council does new work.*
|
package/README.md
CHANGED
package/docs/usage.md
CHANGED
package/electron/ipc-setup.js
CHANGED
|
@@ -184,11 +184,13 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
|
|
|
184
184
|
let cfg = loadConfig();
|
|
185
185
|
if (!cfg) {
|
|
186
186
|
const { toLiveSeedAliases } = require('../src/utils/quick-picks');
|
|
187
|
-
|
|
187
|
+
// issue 214: getCatalogInfo, not getCatalog -- toLiveSeedAliases PERSISTS
|
|
188
|
+
// these routes, so it must see which namespaces were rejected.
|
|
189
|
+
let catalogInfo = { models: [] };
|
|
188
190
|
try {
|
|
189
|
-
|
|
191
|
+
catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
|
|
190
192
|
} catch (_err) { /* offline: pinned seeds */ }
|
|
191
|
-
cfg = { aliases: toLiveSeedAliases(
|
|
193
|
+
cfg = { aliases: toLiveSeedAliases(catalogInfo) };
|
|
192
194
|
}
|
|
193
195
|
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
194
196
|
if (defaultModel) { cfg.default = defaultModel; }
|
package/electron/main.js
CHANGED
|
@@ -327,12 +327,20 @@ function createAmicusWindow() {
|
|
|
327
327
|
async function createSetupWindow() {
|
|
328
328
|
// Lazy-load setup UI to avoid loading it for sidecar mode
|
|
329
329
|
const { buildSetupHTML } = require('./setup-ui');
|
|
330
|
-
const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
|
|
330
|
+
const { resolveQuickPicks, canonicalRoutesFor, toStorableRoute } = require('../src/utils/quick-picks');
|
|
331
331
|
let quickPicks;
|
|
332
332
|
const shortlists = {};
|
|
333
333
|
try {
|
|
334
|
-
|
|
334
|
+
// issue 208: getCatalogInfo (not getCatalog) -- the shortlist needs the
|
|
335
|
+
// per-provider fetch outcomes, or directFormIfSafe's namespace-failure
|
|
336
|
+
// gate cannot fire and a rejected namespace still yields bare direct ids.
|
|
337
|
+
const catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
|
|
338
|
+
const catalog = catalogInfo.models;
|
|
335
339
|
quickPicks = resolveQuickPicks(catalog);
|
|
340
|
+
// issue 214: decide each pick's safe storable form HERE, with the catalog in
|
|
341
|
+
// hand, and ship it as data. The page cannot require() the canonicalisation
|
|
342
|
+
// primitives, and its hand-copy of them dropped their guards.
|
|
343
|
+
for (const p of quickPicks) { p.canonicalRoutes = canonicalRoutesFor(p, catalogInfo); }
|
|
336
344
|
|
|
337
345
|
// issue 138: one vendor shortlist per family card, resolved server-side from
|
|
338
346
|
// the same catalog the quick picks came from (no extra IPC round-trip).
|
|
@@ -341,7 +349,8 @@ async function createSetupWindow() {
|
|
|
341
349
|
try {
|
|
342
350
|
shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
|
|
343
351
|
catalog,
|
|
344
|
-
|
|
352
|
+
providerFailures: catalogInfo.providerFailures,
|
|
353
|
+
recommendedId: toStorableRoute(p, catalogInfo),
|
|
345
354
|
});
|
|
346
355
|
} catch (_e) { /* a shortlist failure must never block the wizard */ }
|
|
347
356
|
}
|
|
@@ -520,21 +529,26 @@ function createSettingsChildWindow() {
|
|
|
520
529
|
// createSetupWindow awaits. A missing or corrupt cache reads back as null
|
|
521
530
|
// and degrades to the same pinned fallback buildSetupHTML already applies
|
|
522
531
|
// when no quickPicks are given.
|
|
523
|
-
const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
|
|
532
|
+
const { resolveQuickPicks, canonicalRoutesFor, toStorableRoute } = require('../src/utils/quick-picks');
|
|
524
533
|
const { readCache } = require('../src/utils/model-catalog');
|
|
525
534
|
let quickPicks;
|
|
526
535
|
const shortlists = {};
|
|
527
536
|
try {
|
|
528
537
|
const cacheDoc = readCache();
|
|
529
538
|
const catalog = cacheDoc ? cacheDoc.models : [];
|
|
539
|
+
// issue 208: the cache doc carries the fetch outcomes for THESE rows.
|
|
540
|
+
const providerFailures = (cacheDoc && cacheDoc.providerFailures) || [];
|
|
530
541
|
quickPicks = resolveQuickPicks(catalog);
|
|
542
|
+
// issue 214: see the note at the other resolveQuickPicks site.
|
|
543
|
+
for (const p of quickPicks) { p.canonicalRoutes = canonicalRoutesFor(p, { models: catalog, providerFailures }); }
|
|
531
544
|
|
|
532
545
|
const { buildModelShortlist } = require('../src/utils/model-shortlist');
|
|
533
546
|
for (const p of quickPicks) {
|
|
534
547
|
try {
|
|
535
548
|
shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
|
|
536
549
|
catalog,
|
|
537
|
-
|
|
550
|
+
providerFailures,
|
|
551
|
+
recommendedId: toStorableRoute(p, { models: catalog, providerFailures }),
|
|
538
552
|
});
|
|
539
553
|
} catch (_e) { /* a shortlist failure must never block the wizard */ }
|
|
540
554
|
}
|
package/electron/setup-ui.js
CHANGED
|
@@ -11,9 +11,8 @@ const { buildLocalSectionHTML } = require('./setup-ui-local');
|
|
|
11
11
|
const { buildLocalScript } = require('./setup-ui-local-script');
|
|
12
12
|
const { getDefaultAliases } = require('../src/utils/config');
|
|
13
13
|
const { getBrandName } = require('./toolbar');
|
|
14
|
-
const { resolveQuickPicks } = require('../src/utils/quick-picks');
|
|
14
|
+
const { resolveQuickPicks, canonicalRoutesFor } = require('../src/utils/quick-picks');
|
|
15
15
|
const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
|
|
16
|
-
const { listDirectProviders } = require('../src/utils/provider-registry');
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* @param {object} [options={}]
|
|
@@ -30,17 +29,26 @@ function buildSetupHTML(options = {}) {
|
|
|
30
29
|
quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
|
|
31
30
|
shortlists = {},
|
|
32
31
|
} = options;
|
|
32
|
+
// Council A1 (PR 215): a pick reaching the page WITHOUT canonicalRoutes makes
|
|
33
|
+
// pickRouteFor fall back to the raw openrouter/... route, which this codebase
|
|
34
|
+
// treats as an EXPLICIT force-OpenRouter literal that never reconsiders
|
|
35
|
+
// direct-first -- so an offline setup (pinned fallback above, catalog
|
|
36
|
+
// unavailable) would pin the user to the gateway permanently. Backfill from an
|
|
37
|
+
// EMPTY catalog: directFormIfSafe is optimistic when nothing disproves the bare
|
|
38
|
+
// form (restoring direct-first) while still refusing for DIVERGENT_VENDORS,
|
|
39
|
+
// which the deleted toBareIfDirect did not.
|
|
40
|
+
const picks = quickPicks.map(p =>
|
|
41
|
+
(p && p.canonicalRoutes) ? p : { ...p, canonicalRoutes: canonicalRoutesFor(p, { models: [] }) });
|
|
33
42
|
const brandName = getBrandName(client);
|
|
34
43
|
const keysHtml = buildKeysStepHTML(PROVIDERS);
|
|
35
|
-
const modelHtml = buildModelStepHTML(
|
|
44
|
+
const modelHtml = buildModelStepHTML(picks, undefined, undefined, shortlists);
|
|
36
45
|
const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
|
|
37
46
|
const css = buildWizardCSS();
|
|
38
47
|
const providersJson = JSON.stringify(PROVIDERS);
|
|
39
|
-
const modelChoicesJson = JSON.stringify(
|
|
48
|
+
const modelChoicesJson = JSON.stringify(picks);
|
|
40
49
|
const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
|
|
41
50
|
const defaultAliasesJson = JSON.stringify(getDefaultAliases());
|
|
42
51
|
const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
|
|
43
|
-
const directProvidersJson = JSON.stringify(listDirectProviders());
|
|
44
52
|
return `<!DOCTYPE html>
|
|
45
53
|
<html><head><meta charset="utf-8"><title>Amicus Setup</title>
|
|
46
54
|
<style>${css}</style></head><body>
|
|
@@ -62,11 +70,11 @@ function buildSetupHTML(options = {}) {
|
|
|
62
70
|
</div>
|
|
63
71
|
</div>
|
|
64
72
|
<div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
|
|
65
|
-
${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson
|
|
73
|
+
${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
|
|
66
74
|
</body></html>`;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson
|
|
77
|
+
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
|
|
70
78
|
const keysJs = buildKeysScript();
|
|
71
79
|
const aliasJs = buildAliasScript();
|
|
72
80
|
const councilJs = buildCouncilScript();
|
|
@@ -83,7 +91,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
83
91
|
var providerNamesData = ${providerNamesJson};
|
|
84
92
|
var defaultAliases = Object.assign(Object.create(null), ${defaultAliasesJson});
|
|
85
93
|
var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
|
|
86
|
-
var directProviders = ${directProvidersJson};
|
|
87
94
|
var routingChoices = {};
|
|
88
95
|
var explicitRouteChoices = {};
|
|
89
96
|
// issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
|
|
@@ -288,20 +295,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
288
295
|
} else { nextBtn.disabled = false; }
|
|
289
296
|
}
|
|
290
297
|
|
|
291
|
-
// #61: an auto-selected (non-explicit) openrouter/<vendor>/<model> route
|
|
292
|
-
// whose vendor also has a direct integration must be stored bare
|
|
293
|
-
// (<vendor>/<model>) so the gateway router can policy-route it direct-first;
|
|
294
|
-
// a stored openrouter/... string is treated as an explicit force-OpenRouter
|
|
295
|
-
// literal and never reconsiders direct-first. Mirrors
|
|
296
|
-
// src/utils/curated-models.js's toCanonicalDefault exactly. Gateway-only
|
|
297
|
-
// vendors (not in directProviders, e.g. qwen/grok/glm/...) pass through
|
|
298
|
-
// unchanged since OpenRouter is their only route anyway.
|
|
299
|
-
function toBareIfDirect(route) {
|
|
300
|
-
if (typeof route !== 'string' || route.indexOf('openrouter/') !== 0) { return route; }
|
|
301
|
-
var rest = route.slice('openrouter/'.length);
|
|
302
|
-
var vendor = rest.split('/')[0];
|
|
303
|
-
return directProviders.indexOf(vendor) !== -1 ? rest : route;
|
|
304
|
-
}
|
|
305
298
|
|
|
306
299
|
// Single source of the route choice for a quick-pick row: explicit pill
|
|
307
300
|
// choice if its key still exists, else first provider with a key, else
|
|
@@ -311,12 +304,12 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
311
304
|
// issue 138: an explicit per-model choice overrides the family flagship.
|
|
312
305
|
var picked = modelChoiceIds[mc.alias];
|
|
313
306
|
if (picked) {
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// (e.g. anthropic) that id can
|
|
318
|
-
// openrouter/<vendor>/... form
|
|
319
|
-
// fabricate a direct id nothing serves.
|
|
307
|
+
// A drilled-down pick is returned VERBATIM -- never canonicalised (unlike
|
|
308
|
+
// the auto-pick below): picked/modelOpenrouterIds come straight from the
|
|
309
|
+
// shortlist's own id/data-or, which the picker already built through
|
|
310
|
+
// directFormIfSafe. For a DIVERGENT_VENDOR (e.g. anthropic) that id can
|
|
311
|
+
// already BE its only-callable openrouter/<vendor>/... form, so touching
|
|
312
|
+
// the prefix here would fabricate a direct id nothing serves.
|
|
320
313
|
if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
|
|
321
314
|
return modelOpenrouterIds[mc.alias] || picked;
|
|
322
315
|
}
|
|
@@ -332,9 +325,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
332
325
|
if (!prov) { prov = provs[0]; }
|
|
333
326
|
}
|
|
334
327
|
var route = mc.routes[prov] || null;
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
328
|
+
// issue 214: the SAFE storable form is decided server-side (quick-picks.js
|
|
329
|
+
// canonicalRoutesFor) and shipped with the pick. The page must not re-derive
|
|
330
|
+
// it: its old hand-copy of stripGatewayPrefix dropped both of that
|
|
331
|
+
// primitive's guards. An explicit "via OpenRouter" pill stays unchanged.
|
|
332
|
+
if (route && !explicitRouteChoices[mc.alias]) {
|
|
333
|
+
route = (mc.canonicalRoutes && mc.canonicalRoutes[prov]) || route;
|
|
334
|
+
}
|
|
338
335
|
return route;
|
|
339
336
|
}
|
|
340
337
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.2",
|
|
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": [
|
|
@@ -229,6 +229,16 @@
|
|
|
229
229
|
"criticSeated"
|
|
230
230
|
]
|
|
231
231
|
},
|
|
232
|
+
"seatsReviewed": {
|
|
233
|
+
"type": "object",
|
|
234
|
+
"description": "#202, optional. How much of the BENCH actually reviewed, derived from runStats: `of` counts every BENCH-role row — `seat`, `critic`, or `lens:<slug>`, exactly the roles seats.js :: buildSeats mints (one per bench seat POST-retry, so a healed seat is counted once and its first attempt is `role:'superseded'`), and `reviewed` counts those whose leg completed. Judges, chair and repair rows are not bench seats and are excluded. EMIT-WHEN-SET: a record with no bench rows carries no key at all, because `0 of 0` would read as a measurement of an empty bench rather than as the absence it is. WHY IT EXISTS: the sibling `seatLoss` above is present only when --critic was requested, and CI runs none — so seat loss was structurally absent from every CI verdict while a two-seat bench published a four-model street-cred table whose dead seats rendered `n/a`, indistinguishable from the legend's neutral (MEASURED, run 4424218c). No `additionalProperties: false` at the top level of this schema means an additive field was always accepted here; this documents the shape rather than changing what is accepted.",
|
|
235
|
+
"properties": {
|
|
236
|
+
"reviewed": { "type": "integer", "minimum": 0, "description": "Bench seats whose leg completed." },
|
|
237
|
+
"of": { "type": "integer", "minimum": 1, "description": "Bench seats benched, post-retry." }
|
|
238
|
+
},
|
|
239
|
+
"required": ["reviewed", "of"],
|
|
240
|
+
"additionalProperties": false
|
|
241
|
+
},
|
|
232
242
|
"degrades": {
|
|
233
243
|
"description": "v4.6 Plan 2: what this run lost — copied verbatim from the sink at verdict assembly. Additive; absent when the sink recorded nothing. v4.9 widened kind with 'info': an announcement that is neither a loss nor a recovery (e.g. a task run's ledger-skipped note) — carried here without degrading the run.",
|
|
234
244
|
"type": "array",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module council/run-retry-window
|
|
3
|
+
* The Stage-1 retry's no-output window: how long a RELAUNCHED leg may stay
|
|
4
|
+
* silent before the backstop kills it.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ EXTRACTED, not shaved (release Constraint 6, and the 300-line gate that put
|
|
7
|
+
* `verdict-seat-loss.js` in its own leaf): #219's correction took run-retry.js to
|
|
8
|
+
* 314/300. Its own module also makes the property directly testable.
|
|
9
|
+
*
|
|
10
|
+
* SL-2 Task 5 (#129): a retry re-runs the SAME model under the SAME conditions,
|
|
11
|
+
* so a latency failure is structurally unhealable — double the window rather
|
|
12
|
+
* than repeat it. ⚠️ #135 C0 took the base 240s -> 600s; deliberate, see
|
|
13
|
+
* CHANGELOG (council A1, PR #182).
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ CLAMPED STRICTLY BELOW the leg timeout, not TO it (#219, council gpt
|
|
16
|
+
* major). `Math.min(2 * backstop, legTimeoutMs)` made the two deadlines EQUAL
|
|
17
|
+
* whenever `2 * backstop >= legTimeoutMs` — exactly CI today (2 x 480000 ===
|
|
18
|
+
* 960000 === `--timeout 16`). The backstop still won, but only by epsilon and
|
|
19
|
+
* only because the poll loop tests its deadline BEFORE sleeping, so the final
|
|
20
|
+
* poll lands just past the wall. That is an undocumented accident of loop order;
|
|
21
|
+
* if it ever lost, the leg would die a generic `timeout` and throw away the
|
|
22
|
+
* named NO_OUTPUT_BACKSTOP diagnosis this clamp exists to preserve.
|
|
23
|
+
*
|
|
24
|
+
* A PROPORTIONAL headroom, not a fixed subtraction: a constant large enough to
|
|
25
|
+
* beat a poll cycle (seconds) would drive a small leg cap to zero or negative,
|
|
26
|
+
* and `ms <= 0` is the documented DISABLE hatch — silently disabling the backstop
|
|
27
|
+
* is far worse than the race it fixes. 5% of any realistic leg cap clears the 2 s
|
|
28
|
+
* poll interval by a wide margin.
|
|
29
|
+
*
|
|
30
|
+
* `2 * 0 === 0` still disables, because `Math.min(0, anything positive) === 0`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {number} baseBackstopMs the first attempt's resolved no-output window
|
|
37
|
+
* @param {number} legTimeoutMs the per-leg hard cap ((o.timeout || 15) * 60_000)
|
|
38
|
+
* @returns {number} the retry's window: doubled, clamped strictly below the cap
|
|
39
|
+
*/
|
|
40
|
+
function retryBackstopMs(baseBackstopMs, legTimeoutMs) {
|
|
41
|
+
// ⚠️ NOT floored at the first attempt's window, and #219 round 2 (glm) asked
|
|
42
|
+
// for exactly that — correctly observing that when `legTimeoutMs <= 2 * base`
|
|
43
|
+
// the retry window comes out slightly SHORTER than the attempt it exists to
|
|
44
|
+
// give room to (480000/480000 -> 456000). The observation is right; the remedy
|
|
45
|
+
// is worse than what it fixes, MEASURED across all three regimes:
|
|
46
|
+
//
|
|
47
|
+
// regime first(effective) unfloored floored unfloored gives
|
|
48
|
+
// cap = 2x base 480000 912000 912000 NAMED backstop
|
|
49
|
+
// cap = base 480000 456000 480000 NAMED backstop
|
|
50
|
+
// cap < base (t=3) 180000 171000 180000 NAMED backstop
|
|
51
|
+
//
|
|
52
|
+
// Flooring pins the window ONTO the leg cap in both degenerate regimes, which
|
|
53
|
+
// is the tie the headroom exists to break — so the leg dies a generic
|
|
54
|
+
// `timeout` and the named diagnosis is lost. That diagnosis is this module's
|
|
55
|
+
// entire purpose. The unfloored cost is bounded at 5% of the window (24 s at
|
|
56
|
+
// CI scale, 9 s at `--timeout 3`), and it is paid only where the leg cap
|
|
57
|
+
// already dominates the backstop. Trading ≤5% of one retry's patience for a
|
|
58
|
+
// named cause on every retry death is the right side of that trade.
|
|
59
|
+
return Math.min(2 * baseBackstopMs, Math.floor(legTimeoutMs * 0.95));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { retryBackstopMs };
|
package/src/council/run-retry.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const { materializeReviews, isAbortExit } = require('./run-launch');
|
|
18
18
|
const runState = require('./run-state');
|
|
19
19
|
const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
|
|
20
|
+
const { retryBackstopMs } = require('./run-retry-window');
|
|
20
21
|
const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
|
|
21
22
|
= require('./run-retry-notes');
|
|
22
23
|
// briefingFor + bindRetryWave live in ./run-retry-launch (v4.8 T-A2 split); the pad/bind core it wraps is stage1-bind.js :: bindPaddedWave (SI-27).
|
|
@@ -54,17 +55,13 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [],
|
|
|
54
55
|
const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [], twins,
|
|
55
56
|
stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
|
|
56
57
|
stillDeadRetryLegs: [], seatOf: new Map(), orphanLegs: [], attemptedSeats: new Set() };
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// the
|
|
60
|
-
// silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
|
|
61
|
-
// the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
|
|
62
|
-
// ⚠️ #135 C0 took this 240s -> 600s; deliberate, see CHANGELOG (council A1, PR #182).
|
|
58
|
+
// The retry window: doubled and clamped strictly below the leg timeout.
|
|
59
|
+
// Reasoning (and #219's correction) lives in ./run-retry-window — extracted
|
|
60
|
+
// for the 300-line gate. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
|
|
63
61
|
const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
|
|
64
|
-
const escalatedBackstopMs =
|
|
65
|
-
|
|
66
|
-
legTimeoutMs
|
|
67
|
-
);
|
|
62
|
+
const escalatedBackstopMs = retryBackstopMs(
|
|
63
|
+
Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs(),
|
|
64
|
+
legTimeoutMs);
|
|
68
65
|
|
|
69
66
|
for (const unit of groupStage1Losses(o, deadWaves, deadLegs, seatOf, twins)) {
|
|
70
67
|
// Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
|
|
@@ -23,6 +23,9 @@ const stage2 = require('./briefings-stage2');
|
|
|
23
23
|
const { parseJudgeOutput } = require('./parse-stage2');
|
|
24
24
|
const { sanitizeName, isAbortExit } = require('./run-launch');
|
|
25
25
|
const runState = require('./run-state');
|
|
26
|
+
// #219 (council, glm minor): `leg.error` is UNTRUSTED provider text. The house
|
|
27
|
+
// sanitizer — one sanitizer, one dialect (utils/text-sanitize.js).
|
|
28
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
26
29
|
const { buildRunStatsEntry } = require('./run-assemble');
|
|
27
30
|
// v4.8 PR3 Task 4: seat binding. artifactName is NOT re-exported from
|
|
28
31
|
// run-launch.js (its exports stop at sanitizeName/isAbortExit), so it comes
|
|
@@ -169,9 +172,16 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
169
172
|
fs.writeFileSync(path.join(o.runDir, name), leg.summary, { mode: 0o600 });
|
|
170
173
|
}
|
|
171
174
|
let conformance = 'clean';
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
// #202: ONE predicate for "this judge never answered at all", shared by the
|
|
176
|
+
// DEAD_LEG classification below and by the degrade it now raises. Spelling it
|
|
177
|
+
// twice is how the two would drift into disagreeing about which judges died —
|
|
178
|
+
// and note it is NOT `leg.status !== 'complete'`: a leg that completes with an
|
|
179
|
+
// EMPTY summary produced nothing either, and the DEAD_LEG arm has always
|
|
180
|
+
// treated it that way.
|
|
181
|
+
const legDied = !(leg.status === 'complete' && leg.summary);
|
|
182
|
+
let parsed = legDied
|
|
183
|
+
? { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] }
|
|
184
|
+
: parseJudgeOutput(leg.summary, parseCtx);
|
|
175
185
|
let attempts = 0;
|
|
176
186
|
// ⚠️ LC-12: the judging text the repair prompt must carry, tracked exactly like
|
|
177
187
|
// Stage-1's `repairing` so `judging` and `parsed.errors` always describe the SAME
|
|
@@ -216,6 +226,40 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
|
|
|
216
226
|
if (parsed.ok) { conformance = 'repaired'; }
|
|
217
227
|
}
|
|
218
228
|
if (!parsed.ok) {
|
|
229
|
+
// #202: THE MISSING THIRD CASE. A dead judge leg still comes back as a leg
|
|
230
|
+
// object, so bindPaddedWave binds it — it is neither `orphan` nor
|
|
231
|
+
// `unbound`, and stage 2 had no case for it. It fell through into
|
|
232
|
+
// judgeResults with `ok:false` and vanished: MEASURED on CI run
|
|
233
|
+
// 32956900910 (wave 9d8029c8-s2), where glm and qwen judges died at +300s
|
|
234
|
+
// with zero tokens and run.json recorded no degrade at all, while the
|
|
235
|
+
// verdict shipped a four-column adjudication matrix two of them never
|
|
236
|
+
// voted in. The one net that might have caught it, `thin-cross-review`,
|
|
237
|
+
// fires only at `usableJudges < 2`; that run had exactly 2 of 4.
|
|
238
|
+
//
|
|
239
|
+
// ⚠️ Emitted with the default kind ('degrade'), so run-degrade.js's sink
|
|
240
|
+
// sets `degraded.value` and the run exits 2. That is a deliberate
|
|
241
|
+
// behaviour change (owner's call): before it, a half-adjudicated verdict
|
|
242
|
+
// could exit 0, and W11 only exited 2 because of an unrelated
|
|
243
|
+
// cost-accounting degrade. An unparseable-but-ANSWERED judge is a
|
|
244
|
+
// different fact and is deliberately excluded — it already darkens the
|
|
245
|
+
// seat's row via `conformance: 'unstructured'`, and it is repairable.
|
|
246
|
+
if (legDied) {
|
|
247
|
+
ctx.degrade.note({
|
|
248
|
+
channel: 'stage2-judge',
|
|
249
|
+
what: `judge ${judge} did not adjudicate`,
|
|
250
|
+
// #219: `why` is PROSE — it renders into run.json, the report and the
|
|
251
|
+
// sticky PR comment — so the provider's text is collapsed to one
|
|
252
|
+
// bounded line. `data.reason` below stays VERBATIM on purpose: it is
|
|
253
|
+
// the machine surface, it is JSON (nothing to inject), and truncating
|
|
254
|
+
// it would cost exactly the fidelity a reader opens run.json for.
|
|
255
|
+
why: `its Stage-2 leg ended '${leg.status}'`
|
|
256
|
+
+ (leg.error ? `: ${collapseExcerpt(leg.error, 200)}` : ''),
|
|
257
|
+
effect: `the cross-review was adjudicated by fewer than the ${judges.length} judges the `
|
|
258
|
+
+ 'bench implies; the run continues and will exit degraded (2)',
|
|
259
|
+
data: { judge, seat: seat ? seat.id : null, waveId: `${o.runId}-s2`,
|
|
260
|
+
status: leg.status, reason: leg.error || null },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
219
263
|
judgeResults.push({ judge, seat, ok: false, order: null, orderSeats: null, adjudications: null,
|
|
220
264
|
conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
|
|
221
265
|
// #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
|
package/src/council/tally.js
CHANGED
|
@@ -6,6 +6,11 @@ const { peersOf, unattributedPeerDrops } = require('./peer-split');
|
|
|
6
6
|
// the seat-keying in it — release Constraint 6 is EXTRACT, never shave).
|
|
7
7
|
// computeStreetCred is re-exported below, so no existing import path moved.
|
|
8
8
|
const { computeStreetCred } = require('./street-cred');
|
|
9
|
+
// #202: the TTFT probe's LAST emit gate — a RE-PROJECTION, so omitting the field
|
|
10
|
+
// here destroyed one already produced rather than failing to produce it. Through
|
|
11
|
+
// v4.9.1 utils/ttft.js's docblock enumerated only the four PRODUCER gates and
|
|
12
|
+
// stopped one short of this one; it now names all five.
|
|
13
|
+
const { isMeasuredTtft } = require('../utils/ttft');
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
|
|
@@ -181,6 +186,13 @@ function tally(input) {
|
|
|
181
186
|
...(r.seat ? { seat: r.seat } : {}),
|
|
182
187
|
status: r.status || 'unknown',
|
|
183
188
|
durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
|
|
189
|
+
// #202: emit-when-VALID, in buildRunStatsEntry's own slot (between
|
|
190
|
+
// durationMs and usage) so G7b's key-order invariant holds for a row that
|
|
191
|
+
// carries it. NOT `durationMs`'s null-coercion above: a null here would be
|
|
192
|
+
// read as a measurement, and absence must keep its one meaning — "no
|
|
193
|
+
// substantive tick was ever observed". The shared predicate is imported
|
|
194
|
+
// rather than hand-spelled; this file has no require-free pin.
|
|
195
|
+
...(isMeasuredTtft(r.ttftMs) ? { ttftMs: r.ttftMs } : {}),
|
|
184
196
|
usage: r.usage || null,
|
|
185
197
|
})),
|
|
186
198
|
tierCounts: countTiers(outFindings),
|