amicus 4.6.0 → 4.6.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 +128 -0
- package/README.md +2 -2
- package/docs/ROADMAP.md +45 -8
- package/docs/configuration.md +13 -9
- package/docs/council.md +7 -2
- package/docs/publishing.md +1 -1
- package/docs/troubleshooting.md +49 -20
- package/docs/usage.md +21 -1
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +71 -0
- package/electron/workspace-ui/workspace-app.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +9 -10
- package/electron/workspace-ui/workspace-seats.js +117 -0
- package/electron/workspace-ui/workspace-verbs.js +1 -0
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +1 -1
- package/schemas/alias-audit.schema.json +6 -1
- package/schemas/council-run.schema.json +14 -0
- package/skills/second-opinion/MODEL-NOTES.md +182 -35
- package/src/cli-handlers-doctor.js +16 -4
- package/src/cli.js +4 -0
- package/src/council/run-chair.js +49 -3
- package/src/council/run-launch.js +4 -0
- package/src/council/run-retry-notes.js +74 -0
- package/src/council/run-retry.js +280 -0
- package/src/council/run-stages.js +41 -11
- package/src/council/verdict.js +8 -1
- package/src/headless.js +119 -9
- package/src/mcp-council-awareness.js +1 -0
- package/src/mcp-server.js +17 -2
- package/src/mcp-tools.js +5 -1
- package/src/opencode-client.js +21 -0
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/models-probe.js +119 -0
- package/src/sidecar/models.js +81 -6
- package/src/utils/alias-audit.js +52 -1
- package/src/utils/base-url-classify.js +74 -0
- package/src/utils/council-presets.js +6 -2
- package/src/utils/curated-models.js +29 -10
- package/src/utils/degrade.js +1 -0
- package/src/utils/doctor-base-url-check.js +41 -0
- package/src/utils/model-fetcher.js +1 -0
- package/src/utils/model-tiers.js +28 -7
- package/src/utils/no-output-backstop.js +48 -0
- package/src/utils/remediation-hints.js +15 -11
- package/src/utils/result-schema.js +29 -2
- package/src/utils/update-notice.js +171 -0
- package/src/workspace/live-normalize.js +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module base-url-classify
|
|
3
|
+
* v4.6.2 PR1 (spec §4, D1/D2): ANTHROPIC_BASE_URL classification, the
|
|
4
|
+
* normalization decision, and the once-per-process notice.
|
|
5
|
+
*
|
|
6
|
+
* The convention split (field-proven by a control pair on run 0084d48c):
|
|
7
|
+
* Anthropic SDKs — including Claude Code itself — treat the var as a HOST and
|
|
8
|
+
* append /v1 themselves; OpenCode's provider layer treats it as the FULL
|
|
9
|
+
* prefix and appends /messages. A host-form value is therefore correct for
|
|
10
|
+
* Claude Code and fatal for every OpenCode direct-anthropic leg
|
|
11
|
+
* (host/messages -> 404 "Not Found").
|
|
12
|
+
*
|
|
13
|
+
* Forms: absent (unset/blank) · host (path '' or '/') · v1 (path ends /v1)
|
|
14
|
+
* · other (any other path, or unparseable — passed through untouched; an
|
|
15
|
+
* exotic proxy serving /messages at a custom root stays possible, D1).
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
/** @param {string|undefined|null} value @returns {{form:string, normalized:string|null}} */
|
|
20
|
+
function classifyBaseUrl(value) {
|
|
21
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
22
|
+
return { form: 'absent', normalized: null };
|
|
23
|
+
}
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
let url;
|
|
26
|
+
try { url = new URL(trimmed); } catch { return { form: 'other', normalized: null }; }
|
|
27
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
28
|
+
if (path === '') {
|
|
29
|
+
return { form: 'host', normalized: trimmed.replace(/\/+$/, '') + '/v1' };
|
|
30
|
+
}
|
|
31
|
+
if (path.endsWith('/v1')) { return { form: 'v1', normalized: null }; }
|
|
32
|
+
return { form: 'other', normalized: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The baseURL override the OpenCode server config should carry, or null.
|
|
37
|
+
* Null when: var absent, already /v1, nonstandard path, or normalization
|
|
38
|
+
* disabled via AMICUS_BASE_URL_NORMALIZE=0 (D1's escape hatch).
|
|
39
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
function resolveBaseUrlOverride(env = process.env) {
|
|
43
|
+
if (env.AMICUS_BASE_URL_NORMALIZE === '0') { return null; }
|
|
44
|
+
const { form, normalized } = classifyBaseUrl(env.ANTHROPIC_BASE_URL);
|
|
45
|
+
return form === 'host' ? normalized : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let noticeShown = false;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One notice per process (D2): the server may start many times (shared-server
|
|
52
|
+
* retries, fanout waves) and the treatment is identical every time.
|
|
53
|
+
* @param {string} value - the raw env value seen
|
|
54
|
+
* @param {string} normalized - the value handed to the engine config
|
|
55
|
+
* @param {{write?:Function, logger?:object}} [deps] - test seams
|
|
56
|
+
*/
|
|
57
|
+
function announceBaseUrlNormalizationOnce(value, normalized, deps = {}) {
|
|
58
|
+
if (noticeShown) { return; }
|
|
59
|
+
noticeShown = true;
|
|
60
|
+
const write = deps.write || (s => process.stderr.write(s));
|
|
61
|
+
const log = deps.logger || require('./logger').logger;
|
|
62
|
+
write(`Notice: ANTHROPIC_BASE_URL is host-form (${value}); passing ${normalized} to the engine `
|
|
63
|
+
+ '(Anthropic SDKs append /v1 themselves; OpenCode treats the value as a full prefix; '
|
|
64
|
+
+ 'set AMICUS_BASE_URL_NORMALIZE=0 to disable).\n');
|
|
65
|
+
log.info('ANTHROPIC_BASE_URL normalized for engine config', { value, normalized });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Test seam: reset the once-guard. */
|
|
69
|
+
function _resetBaseUrlNotice() { noticeShown = false; }
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
classifyBaseUrl, resolveBaseUrlOverride,
|
|
73
|
+
announceBaseUrlNormalizationOnce, _resetBaseUrlNotice,
|
|
74
|
+
};
|
|
@@ -42,13 +42,17 @@ const BUDGET_ALIASES = ['minimax', 'qwen-coder', 'deepseek'];
|
|
|
42
42
|
/**
|
|
43
43
|
* Frontier bench: three premium-flagship DEFAULT_ALIASES entries, one per
|
|
44
44
|
* vendor family, verified against the same catalog snapshot:
|
|
45
|
-
* gpt-pro openrouter/openai/gpt-5.
|
|
46
|
-
* opus openrouter/anthropic/claude-opus-
|
|
45
|
+
* gpt-pro openrouter/openai/gpt-5.6-sol-pro $0.000005 / $0.00003
|
|
46
|
+
* opus openrouter/anthropic/claude-opus-5 $0.000005 / $0.000025
|
|
47
47
|
* gemini-pro openrouter/google/gemini-3.1-pro-preview $0.000002 / $0.000012
|
|
48
48
|
* These are the three highest total (prompt+completion) prices in
|
|
49
49
|
* DEFAULT_ALIASES that are also each a distinct vendor family (OpenAI /
|
|
50
50
|
* Anthropic / Google) — `gpt` and `codex` (also OpenAI) and `claude`/`sonnet`
|
|
51
51
|
* (also Anthropic) were skipped as same-family duplicates of the pick above.
|
|
52
|
+
* (opus re-pinned to claude-opus-5 on 2026-08-04 at the same live price;
|
|
53
|
+
* gpt-pro retargeted to gpt-5.6-sol-pro on 2026-08-04 — cheaper than the
|
|
54
|
+
* old gpt-5.5-pro pin but still OpenAI's premium tier, so the selection
|
|
55
|
+
* logic above is unchanged.)
|
|
52
56
|
*/
|
|
53
57
|
const FRONTIER_ALIASES = ['gpt-pro', 'opus', 'gemini-pro'];
|
|
54
58
|
|
|
@@ -21,9 +21,11 @@ const { isDirectProvider } = require('./provider-registry');
|
|
|
21
21
|
* resolve live from the catalog. A per-provider `fallback` entry is
|
|
22
22
|
* OPTIONAL: when absent and the catalog cannot resolve that namespace,
|
|
23
23
|
* the direct route is omitted (no pinned guess is better than a wrong one).
|
|
24
|
-
* `gpt`'s pattern intentionally matches
|
|
25
|
-
* (gpt-5.5, gpt-6)
|
|
26
|
-
*
|
|
24
|
+
* `gpt`'s pattern intentionally matches a plain numeric flagship id
|
|
25
|
+
* (gpt-5.5, gpt-6) OR that id's `-terra` tier variant (gpt-5.6-terra), and
|
|
26
|
+
* excludes every other suffixed variant (-pro/-mini/-codex/-sol/-luna) —
|
|
27
|
+
* see the tier-semantics comment on the entry below.
|
|
28
|
+
* Pinned ids verified against the live catalog 2026-08-04.
|
|
27
29
|
*/
|
|
28
30
|
const FAMILIES = [
|
|
29
31
|
{ alias: 'gemini', label: 'Gemini Flash-class', blurb: 'fast, large context',
|
|
@@ -37,17 +39,26 @@ const FAMILIES = [
|
|
|
37
39
|
idPattern: /^gemini-[\d.]+-pro(-preview|-exp|-latest)?$/,
|
|
38
40
|
directProviders: ['google'],
|
|
39
41
|
fallback: { openrouter: 'openrouter/google/gemini-3.1-pro-preview' } },
|
|
42
|
+
// 5.6 split the flagship into tiers: sol (premium, $5/$30), terra (mid,
|
|
43
|
+
// $1/$6), luna (economy, $0.10/$0.60), each with a -pro sibling, plus the
|
|
44
|
+
// unrelated gpt-5.3-codex family. Owner ruling: `gpt` tracks the TERRA
|
|
45
|
+
// (mid) tier — sol/luna/pro variants and codex are excluded deliberately.
|
|
46
|
+
// Bare numeric ids (gpt-5.5-style) stay matched as a within-family
|
|
47
|
+
// fallback if the terra naming ever disappears from the catalog.
|
|
40
48
|
{ alias: 'gpt', label: 'GPT flagship', blurb: 'strong coding',
|
|
41
49
|
vendorPath: 'openai',
|
|
42
|
-
idPattern: /^gpt-[\d.]
|
|
50
|
+
idPattern: /^gpt-[\d.]+(-terra)?$/,
|
|
43
51
|
directProviders: ['openai'],
|
|
44
|
-
fallback: { openrouter: 'openrouter/openai/gpt-5.
|
|
52
|
+
fallback: { openrouter: 'openrouter/openai/gpt-5.6-terra' } },
|
|
45
53
|
{ alias: 'opus', label: 'Claude Opus-class', blurb: 'deep analysis',
|
|
46
54
|
vendorPath: 'anthropic',
|
|
47
55
|
idPattern: /^claude-opus-[\d.-]+$/,
|
|
48
56
|
directProviders: ['anthropic'],
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
// claude-opus-5 has no dotted version segment, so the two forms coincide —
|
|
58
|
+
// the anthropic: route is still AUTHORED (DIVERGENT_VENDORS), never derived.
|
|
59
|
+
// Direct id verified against Anthropic docs 2026-08-04.
|
|
60
|
+
fallback: { openrouter: 'openrouter/anthropic/claude-opus-5',
|
|
61
|
+
anthropic: 'anthropic/claude-opus-5' } },
|
|
51
62
|
{ alias: 'deepseek', label: 'DeepSeek flagship', blurb: 'open-source',
|
|
52
63
|
vendorPath: 'deepseek',
|
|
53
64
|
idPattern: /^deepseek-v[\d.]+(-pro)?$/,
|
|
@@ -58,10 +69,15 @@ const FAMILIES = [
|
|
|
58
69
|
|
|
59
70
|
/**
|
|
60
71
|
* Alias-only entries (no wizard quick pick); openrouter route only.
|
|
61
|
-
* Refreshed against the live catalog 2026-
|
|
72
|
+
* Refreshed against the live catalog 2026-08-04.
|
|
62
73
|
*/
|
|
63
74
|
const CARDLESS = [
|
|
64
|
-
|
|
75
|
+
// gpt-pro: the 5.6 premium (sol) tier's pro sibling, priced at its base
|
|
76
|
+
// tier ($5/$30 per Mtok). Owner ruling 2026-08-04: retargeted off
|
|
77
|
+
// gpt-5.5-pro ($30/$180 — still served, but expected to sunset with the
|
|
78
|
+
// 5.5 line). `gpt-pro` tracks SOL while the `gpt` family tracks terra —
|
|
79
|
+
// see the tier-semantics comment on the `gpt` family above.
|
|
80
|
+
{ alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
|
|
65
81
|
// codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
|
|
66
82
|
{ alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
|
|
67
83
|
{ alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
|
|
@@ -75,7 +91,10 @@ const CARDLESS = [
|
|
|
75
91
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
76
92
|
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
77
93
|
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
|
78
|
-
|
|
94
|
+
// devstral was dropped 2026-08-04 (owner ruling): OpenRouter delisted the
|
|
95
|
+
// whole devstral family and the alias had no other route. No retarget — no
|
|
96
|
+
// served model is a devstral successor ("no pinned guess is better than a
|
|
97
|
+
// wrong one"); `mistral` remains the vendor's alias.
|
|
79
98
|
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.1' } },
|
|
80
99
|
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.7' } },
|
|
81
100
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
package/src/utils/degrade.js
CHANGED
|
@@ -16,6 +16,7 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
16
16
|
'dead-leg', 'dead-wave', 'budget-refusal', 'shared-server-unavailable',
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
|
+
'stage1-retry',
|
|
19
20
|
'internal',
|
|
20
21
|
// doctor channels
|
|
21
22
|
'doctor-check-failed', 'doctor-fix',
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-base-url-check
|
|
3
|
+
* v4.6.2 PR1 (spec §4): the 'anthropic-base-url' doctor row.
|
|
4
|
+
*
|
|
5
|
+
* VERIFIABLE voice (BACKLOG ruling): states only what it string-inspected.
|
|
6
|
+
* It always prints the value the process SEES — the var can live ONLY in a
|
|
7
|
+
* parent process env (the field case: set in the Claude Code app process,
|
|
8
|
+
* absent from every persisted scope on disk), so the seen value IS the
|
|
9
|
+
* diagnostic; "where it is set" may be unfindable.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { classifyBaseUrl } = require('./base-url-classify');
|
|
14
|
+
|
|
15
|
+
/** @param {{env?:NodeJS.ProcessEnv}} [d] @returns {{id,name,status,message,hint}} */
|
|
16
|
+
function evaluateAnthropicBaseUrl(d = {}) {
|
|
17
|
+
const id = 'anthropic-base-url'; const name = 'ANTHROPIC_BASE_URL';
|
|
18
|
+
const env = d.env || process.env;
|
|
19
|
+
const value = env.ANTHROPIC_BASE_URL;
|
|
20
|
+
const { form, normalized } = classifyBaseUrl(value);
|
|
21
|
+
if (form === 'absent') {
|
|
22
|
+
return { id, name, status: 'ok', message: 'not set', hint: null };
|
|
23
|
+
}
|
|
24
|
+
if (form === 'v1') {
|
|
25
|
+
return { id, name, status: 'ok', message: `${value} (full-prefix form)`, hint: null };
|
|
26
|
+
}
|
|
27
|
+
if (form === 'host') {
|
|
28
|
+
const disabled = env.AMICUS_BASE_URL_NORMALIZE === '0';
|
|
29
|
+
const treatment = disabled
|
|
30
|
+
? 'normalization is disabled (AMICUS_BASE_URL_NORMALIZE=0) — direct-anthropic legs will 404'
|
|
31
|
+
: `amicus passes ${normalized} to the engine`;
|
|
32
|
+
return {
|
|
33
|
+
id, name, status: 'warn',
|
|
34
|
+
message: `host-form: ${value} — Anthropic SDKs append /v1; OpenCode treats it as the full prefix; ${treatment}`,
|
|
35
|
+
hint: disabled ? `set ANTHROPIC_BASE_URL=${normalized} (or unset AMICUS_BASE_URL_NORMALIZE)` : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { id, name, status: 'ok', message: `${value} (nonstandard path — passed through unchanged)`, hint: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { evaluateAnthropicBaseUrl };
|
|
@@ -16,6 +16,7 @@ const https = require('https');
|
|
|
16
16
|
* would mislabel a direct-API request for it as valid.
|
|
17
17
|
*/
|
|
18
18
|
const ANTHROPIC_MODELS = [
|
|
19
|
+
{ id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', contextLength: null, pricing: null },
|
|
19
20
|
{ id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
|
|
20
21
|
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
|
|
21
22
|
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
|
package/src/utils/model-tiers.js
CHANGED
|
@@ -28,7 +28,13 @@ const { pickCurrent } = require('./quick-picks');
|
|
|
28
28
|
const { isDirectProvider } = require('./provider-registry');
|
|
29
29
|
const { toDefaultAliases, listCuratedRoutes } = require('./curated-models');
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Tier pattern table: each tier holds a regex — or an ORDERED regex list,
|
|
33
|
+
* tried first-match-wins — over the model segment after `<vendor>/` (or
|
|
34
|
+
* `openrouter/<vendor>/`). List order expresses preference, which a single
|
|
35
|
+
* regex cannot: ids sort numeric-descending, so `-pro` siblings would
|
|
36
|
+
* otherwise outrank their same-priced base.
|
|
37
|
+
*/
|
|
32
38
|
const TIERS = {
|
|
33
39
|
anthropic: {
|
|
34
40
|
economy: /^claude-haiku-/,
|
|
@@ -36,9 +42,17 @@ const TIERS = {
|
|
|
36
42
|
frontier: /^claude-opus-/,
|
|
37
43
|
},
|
|
38
44
|
openai: {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
// The 5.6 line renamed the flagship into tiers — luna ($0.10/$0.60 per
|
|
46
|
+
// Mtok in/out), terra ($1/$6), sol ($5/$30), each with a -pro sibling
|
|
47
|
+
// priced at its base tier; no bare/-mini/-pro 5.6 ids exist. Owner
|
|
48
|
+
// ruling 2026-08-04: economy→luna, balanced→terra, frontier→sol, each
|
|
49
|
+
// preferring the base name, then its -pro sibling, then the 5.5-era
|
|
50
|
+
// naming so a stale catalog still resolves. balanced's primary is the
|
|
51
|
+
// same bare-or-terra pattern the `gpt` family uses (curated-models.js),
|
|
52
|
+
// so a future return to bare flagship ids is tracked automatically.
|
|
53
|
+
economy: [/^gpt-[\d.]+-luna$/, /^gpt-[\d.]+-luna-pro$/, /^gpt-[\d.]+-mini$/],
|
|
54
|
+
balanced: [/^gpt-[\d.]+(-terra)?$/, /^gpt-[\d.]+-terra-pro$/],
|
|
55
|
+
frontier: [/^gpt-[\d.]+-sol$/, /^gpt-[\d.]+-sol-pro$/, /^gpt-[\d.]+-pro$/],
|
|
42
56
|
},
|
|
43
57
|
google: {
|
|
44
58
|
economy: /^gemini-[\d.]+-flash-lite/,
|
|
@@ -77,9 +91,16 @@ function buildGatewayOnlyAliasMap() {
|
|
|
77
91
|
|
|
78
92
|
const GATEWAY_ONLY_ALIAS = buildGatewayOnlyAliasMap();
|
|
79
93
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
/**
|
|
95
|
+
* First match wins over a tier's ordered patterns; within each pattern the
|
|
96
|
+
* newest matching id is picked, direct namespace preferred over OpenRouter's.
|
|
97
|
+
*/
|
|
98
|
+
function pickForTier(catalog, vendor, patterns) {
|
|
99
|
+
for (const regex of [].concat(patterns)) {
|
|
100
|
+
const pick = pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
|
|
101
|
+
if (pick) { return pick; }
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
/** True when the catalog has ANY row (any tier) under this vendor's namespace, in either gateway. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/no-output-backstop
|
|
3
|
+
* v4.6.2 PR2 (spec §5, D4): fail a headless leg fast when the model produces
|
|
4
|
+
* ZERO output, reasoning, and tool calls — the "accepted but not serving"
|
|
5
|
+
* class (the v4.6.1 gemini release-gate incident: requests accepted, zero
|
|
6
|
+
* tokens, three suites burned 130s timeouts each to learn nothing).
|
|
7
|
+
*
|
|
8
|
+
* Pure state machine, loop-driven (no timers of its own — the poll loop
|
|
9
|
+
* ticks it): armed at leg start, DISARMED PERMANENTLY by the first
|
|
10
|
+
* `progressed` tick (a 30-90s cold-prefill local model is never affected),
|
|
11
|
+
* fired when the deadline passes with nothing ever observed. `fired` is
|
|
12
|
+
* terminal. `ms <= 0` never arms — 0 is the documented escape hatch, which
|
|
13
|
+
* is why the env resolver uses envNumber (explicit 0 honored) rather than
|
|
14
|
+
* the `Number(env) || default` idiom.
|
|
15
|
+
*
|
|
16
|
+
* PR3's live probe reuses this with a 30s override — ms is an input; only
|
|
17
|
+
* the exported resolver reads the environment.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { envNumber } = require('./env-num');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 120000;
|
|
24
|
+
|
|
25
|
+
/** @param {object} [env] test seam; defaults to process.env */
|
|
26
|
+
function resolveNoOutputBackstopMs(env) {
|
|
27
|
+
return envNumber('AMICUS_NO_OUTPUT_BACKSTOP_MS', DEFAULT_NO_OUTPUT_BACKSTOP_MS, env);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{ms:number, startedAt:number}} opts
|
|
32
|
+
* @returns {{tick:(progressed:boolean, nowMs:number)=>string, state:()=>string}}
|
|
33
|
+
*/
|
|
34
|
+
function createNoOutputBackstop({ ms, startedAt }) {
|
|
35
|
+
let state = ms > 0 ? 'armed' : 'disarmed';
|
|
36
|
+
const deadline = startedAt + ms;
|
|
37
|
+
return {
|
|
38
|
+
tick(progressed, nowMs) {
|
|
39
|
+
if (state !== 'armed') { return state; }
|
|
40
|
+
if (progressed) { state = 'disarmed'; return state; }
|
|
41
|
+
if (nowMs >= deadline) { state = 'fired'; }
|
|
42
|
+
return state;
|
|
43
|
+
},
|
|
44
|
+
state() { return state; },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { resolveNoOutputBackstopMs, createNoOutputBackstop, DEFAULT_NO_OUTPUT_BACKSTOP_MS };
|
|
@@ -49,21 +49,19 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
49
49
|
/** Electron absent — reinstall to add the interactive GUI (headless still works). */
|
|
50
50
|
reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
* Electron present but broken (ABI mismatch / partial unpack). Delete the
|
|
54
|
-
* vendored copy and reinstall to force a clean rebuild.
|
|
55
|
-
*/
|
|
56
|
-
rebuildElectron:
|
|
57
|
-
'rm -rf node_modules/electron && npm install -g amicus (rebuild Electron after an ABI mismatch or partial unpack)',
|
|
58
|
-
|
|
59
52
|
/** Point the user at the single recovery hub. */
|
|
60
53
|
runDoctor: 'run: amicus doctor (diagnoses config, keys, engine & MCP, with copy-paste fixes)',
|
|
61
54
|
|
|
62
55
|
/**
|
|
63
|
-
* Self-heal the optional Electron GUI in place (#56).
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
56
|
+
* Self-heal the optional Electron GUI in place (#56). The convergence target
|
|
57
|
+
* for the "reinstall to fix Electron" hints — it provisions the binary from
|
|
58
|
+
* cache (or downloads on demand) WITHOUT a global reinstall, so it can't
|
|
59
|
+
* loop the way `npm install -g amicus` could when the rollback recurs.
|
|
60
|
+
* (`rebuildElectron`, the manual rm-rf-and-reinstall variant, was deleted
|
|
61
|
+
* 2026-08-03 by owner ruling: no live call site once this hint became the
|
|
62
|
+
* target, and its prose asserted unverified causes. A reintroduction must
|
|
63
|
+
* use the unverified-cause voice — absence-pinned in
|
|
64
|
+
* tests/remediation-hints.test.js.)
|
|
67
65
|
*/
|
|
68
66
|
doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
|
|
69
67
|
|
|
@@ -80,6 +78,12 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
80
78
|
* atomic tmp-write and rename leaves a stray temp file in the config dir
|
|
81
79
|
* forever. `doctor --fix` sweeps files older than 60s (never a live writer's
|
|
82
80
|
* ms-lived tmp).
|
|
81
|
+
*
|
|
82
|
+
* Voice ruling (Christian, 2026-08-03): this hint keeps its confident cause.
|
|
83
|
+
* "Left by an interrupted write" is definitional, not a guess — the atomic
|
|
84
|
+
* write pattern admits no other producer, and the age gate excludes live
|
|
85
|
+
* writers — so the Plan 3 unverified-cause voice deliberately does NOT
|
|
86
|
+
* apply. Do not re-file it against that criterion.
|
|
83
87
|
*/
|
|
84
88
|
sweepSessionIndexTmp:
|
|
85
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
@@ -192,10 +192,32 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
|
|
|
192
192
|
* of curated DEFAULT aliases (toGatewayRoutes() vs. the live catalog),
|
|
193
193
|
* distinct from the flat `stale` audit above. Defaults to [] so existing
|
|
194
194
|
* callers that omit it are unaffected.
|
|
195
|
+
* `drifted` (v4.6.2 PR1, 2A) is additive: stored user-config aliases whose
|
|
196
|
+
* target is still catalog-live but behind the current quick-pick family
|
|
197
|
+
* resolution (findDriftedStoredAliases). Defaults to [] so existing callers
|
|
198
|
+
* that omit it are unaffected. Paired with an additive `driftedCount`
|
|
199
|
+
* (drifted.length), mirroring the staleCount/stale and
|
|
200
|
+
* gatewayFindingsCount/gatewayFindings pairs above.
|
|
201
|
+
* `probe` (v4.6.2 PR3, spec §6 D5) is additive: the `--live` opt-in per-alias
|
|
202
|
+
* probe outcomes (probeStoredAliases — served/accepted-but-silent/error), one
|
|
203
|
+
* row per stored alias actually probed. Defaults to [] so every call without
|
|
204
|
+
* --live (i.e. every existing caller) is unaffected. Paired with an additive
|
|
205
|
+
* `probeCount` (probe.length), mirroring the driftedCount/drifted pair above.
|
|
206
|
+
* `probeSkipped` (v4.6.2 PR3 Task 4) is additive: null when the probe ran or
|
|
207
|
+
* `--live` wasn't requested; otherwise a stable reason slug (e.g.
|
|
208
|
+
* 'catalog-unavailable') for the one case this function can see — the
|
|
209
|
+
* `--refresh --check --live` skip (runRefresh returns before this is ever
|
|
210
|
+
* called) is announced by the caller instead, since that path emits a
|
|
211
|
+
* model-catalog doc, not this one.
|
|
195
212
|
* @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean,
|
|
196
|
-
* gatewayFindings?: Array<{alias,gateway,kind,model,expected?}
|
|
213
|
+
* gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>,
|
|
214
|
+
* drifted?: Array<{alias,stored,current}>,
|
|
215
|
+
* probe?: Array<{alias,target,outcome,detail,cost}>,
|
|
216
|
+
* probeSkipped?: string|null}} opts
|
|
197
217
|
*/
|
|
198
|
-
function buildAuditDoc({
|
|
218
|
+
function buildAuditDoc({
|
|
219
|
+
stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null
|
|
220
|
+
}) {
|
|
199
221
|
return {
|
|
200
222
|
schemaVersion: SCHEMA_VERSION,
|
|
201
223
|
type: 'alias-audit',
|
|
@@ -204,6 +226,11 @@ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
|
|
|
204
226
|
stale,
|
|
205
227
|
gatewayFindingsCount: gatewayFindings.length,
|
|
206
228
|
gatewayFindings,
|
|
229
|
+
driftedCount: drifted.length,
|
|
230
|
+
drifted,
|
|
231
|
+
probeCount: probe.length,
|
|
232
|
+
probe,
|
|
233
|
+
probeSkipped,
|
|
207
234
|
};
|
|
208
235
|
}
|
|
209
236
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/update-notice — "a newer amicus exists" for the MCP channel
|
|
3
|
+
*
|
|
4
|
+
* The MCP server is the one entry point that skips bin/amicus.js's update
|
|
5
|
+
* banner (deliberately — stdout is protocol). This module is the MCP-shaped
|
|
6
|
+
* replacement (spec docs/superpowers/specs/2026-08-03-mcp-update-notice-design.md):
|
|
7
|
+
* updater.js's cached check rendered as ONE appended text content block on the
|
|
8
|
+
* first successful tool result of the process (latched, D1), plus an always-on
|
|
9
|
+
* line in amicus_guide.
|
|
10
|
+
*
|
|
11
|
+
* Voice contract (v4.6 hint ruling): the version pair is verified fact; the
|
|
12
|
+
* upgrade instruction is stated as fact only when derived from a readable MCP
|
|
13
|
+
* registration config — fallbacks keep the "likely" hedge. Everything here is
|
|
14
|
+
* advisory: every export swallows its own failures rather than throwing into
|
|
15
|
+
* a tool result.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/** Upgrade wordings (spec §4). Config-derived rows are verified-voiced;
|
|
21
|
+
* NPX_CACHED_LINE keeps the hedge — the config read is best-effort. */
|
|
22
|
+
const GLOBAL_LINE = 'Run `npm install -g amicus`, then restart your MCP client.';
|
|
23
|
+
const NPX_LATEST_LINE = 'Restart your MCP client — it launches `amicus@latest` and will pick up the new version.';
|
|
24
|
+
const NPX_CACHED_LINE = 'Your MCP config likely launches a cached/pinned npx copy; '
|
|
25
|
+
+ 'point it at `npx -y amicus@latest mcp` (or clear the npx cache), then restart your MCP client.';
|
|
26
|
+
const GENERIC_LINE = 'Upgrade your amicus install, then restart your MCP client.';
|
|
27
|
+
|
|
28
|
+
const CHANGELOG_URL = 'https://github.com/BourbonDog/amicus/blob/main/CHANGELOG.md';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Flavor of THIS install — the copy serving the current process. Pure path
|
|
32
|
+
* heuristic on the realpath of our own package.json (no `npm root -g` shellout
|
|
33
|
+
* on the tool-result path): a `_npx` segment is the npx cache; any other
|
|
34
|
+
* `node_modules` home is a global-style install; no `node_modules` at all is a
|
|
35
|
+
* dev clone or similar.
|
|
36
|
+
* @param {{fs?: object, pkgPath?: string}} [deps]
|
|
37
|
+
* @returns {'global'|'npx'|'other'}
|
|
38
|
+
*/
|
|
39
|
+
function classifySelfInstall(deps = {}) {
|
|
40
|
+
const fs = deps.fs || require('fs');
|
|
41
|
+
const pkgPath = deps.pkgPath || require('./version-info').PKG_PATH;
|
|
42
|
+
try {
|
|
43
|
+
// Split the raw realpath — NOT path.dirname first: dirname is platform-
|
|
44
|
+
// bound (posix dirname collapses a foreign backslash path to '.', the CI
|
|
45
|
+
// path-fixture failure class), and the basename 'package.json' can never
|
|
46
|
+
// collide with the segment names probed here.
|
|
47
|
+
const parts = fs.realpathSync(pkgPath).split(/[\\/]/);
|
|
48
|
+
if (parts.includes('_npx')) { return 'npx'; }
|
|
49
|
+
if (parts.includes('node_modules')) { return 'global'; }
|
|
50
|
+
return 'other';
|
|
51
|
+
} catch {
|
|
52
|
+
return 'other';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when some RAW config arg is the amicus package token pinned `@latest`.
|
|
58
|
+
* Raw on purpose: mcp-self-identity's normalizeToken strips `@version`
|
|
59
|
+
* suffixes, which is exactly the information this check needs.
|
|
60
|
+
* @param {{args?: unknown[]}|null|undefined} config
|
|
61
|
+
*/
|
|
62
|
+
function pinsAmicusLatest(config) {
|
|
63
|
+
const args = Array.isArray(config && config.args) ? config.args : [];
|
|
64
|
+
return args.some((a) => {
|
|
65
|
+
const t = String(a).toLowerCase().replace(/\\/g, '/');
|
|
66
|
+
const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
|
|
67
|
+
return base === 'amicus@latest';
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one correct upgrade move for this install (spec §4), chosen
|
|
73
|
+
* config-first (what a RESTART will launch), self-path fallback.
|
|
74
|
+
* Never throws; worst case is the generic line.
|
|
75
|
+
* @param {{readConfig?: Function, classifyLaunch?: Function, selfFlavor?: Function}} [deps]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function upgradeInstruction(deps = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const readConfig = deps.readConfig
|
|
81
|
+
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
82
|
+
const classifyLaunchFn = deps.classifyLaunch
|
|
83
|
+
|| require('./engine-install-scan').classifyLaunch;
|
|
84
|
+
const selfFlavor = deps.selfFlavor || (() => classifySelfInstall(deps));
|
|
85
|
+
|
|
86
|
+
let config = null;
|
|
87
|
+
try { config = readConfig(); } catch { config = null; }
|
|
88
|
+
|
|
89
|
+
const launch = classifyLaunchFn(config);
|
|
90
|
+
if (launch === 'npx') {
|
|
91
|
+
return pinsAmicusLatest(config) ? NPX_LATEST_LINE : NPX_CACHED_LINE;
|
|
92
|
+
}
|
|
93
|
+
if (launch === 'path') {
|
|
94
|
+
// A path registration launches (approximately) the running copy — let
|
|
95
|
+
// its flavor pick between the npm-global move and the generic one.
|
|
96
|
+
return selfFlavor() === 'global' ? GLOBAL_LINE : GENERIC_LINE;
|
|
97
|
+
}
|
|
98
|
+
// 'none' / 'unknown' — config unreadable or unrecognized: self-path fallback.
|
|
99
|
+
const flavor = selfFlavor();
|
|
100
|
+
if (flavor === 'global') { return GLOBAL_LINE; }
|
|
101
|
+
if (flavor === 'npx') { return NPX_CACHED_LINE; }
|
|
102
|
+
return GENERIC_LINE;
|
|
103
|
+
} catch {
|
|
104
|
+
return GENERIC_LINE;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The full notice text: verified version pair + instruction + changelog.
|
|
110
|
+
* @param {{current: string, latest: string}} info
|
|
111
|
+
* @param {string} [instruction] - resolved lazily when omitted
|
|
112
|
+
*/
|
|
113
|
+
function buildUpdateNotice(info, instruction) {
|
|
114
|
+
return `Update available: amicus v${info.current} → v${info.latest}. `
|
|
115
|
+
+ `${instruction || upgradeInstruction()} Changelog: ${CHANGELOG_URL}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Once-per-process latch (spec D1). Flips ONLY on an actual append. */
|
|
119
|
+
let _noticeShown = false;
|
|
120
|
+
|
|
121
|
+
/** Test seam: re-arm the latch. */
|
|
122
|
+
function _resetLatchForTests() { _noticeShown = false; }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The seam the MCP registration wrapper routes EVERY result through: append
|
|
126
|
+
* the notice block to the first successful tool result of this process, then
|
|
127
|
+
* stay quiet. No-op on isError results, unknown update state, malformed
|
|
128
|
+
* results, or any internal failure — the original result always comes back.
|
|
129
|
+
* @param {{content?: Array, isError?: boolean}|null} result
|
|
130
|
+
* @param {{getUpdateInfo?: Function}} [deps]
|
|
131
|
+
*/
|
|
132
|
+
function maybeAppendUpdateNotice(result, deps = {}) {
|
|
133
|
+
try {
|
|
134
|
+
if (_noticeShown) { return result; }
|
|
135
|
+
if (!result || result.isError || !Array.isArray(result.content)) { return result; }
|
|
136
|
+
const getUpdateInfo = deps.getUpdateInfo || require('./updater').getUpdateInfo;
|
|
137
|
+
const info = getUpdateInfo();
|
|
138
|
+
if (!info || !info.hasUpdate) { return result; }
|
|
139
|
+
result.content.push({ type: 'text', text: buildUpdateNotice(info) });
|
|
140
|
+
_noticeShown = true;
|
|
141
|
+
return result;
|
|
142
|
+
} catch {
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The amicus_guide version-line suffix (NOT latched — the guide is the
|
|
149
|
+
* on-demand surface), or null when there is nothing to say.
|
|
150
|
+
* @param {{getUpdateInfo?: Function}} [deps] - plus upgradeInstruction seams
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
function guideUpdateLine(deps = {}) {
|
|
154
|
+
try {
|
|
155
|
+
const getUpdateInfo = deps.getUpdateInfo || require('./updater').getUpdateInfo;
|
|
156
|
+
const info = getUpdateInfo();
|
|
157
|
+
if (!info || !info.hasUpdate) { return null; }
|
|
158
|
+
return `**Update available: v${info.latest}** — ${upgradeInstruction(deps)}`;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
classifySelfInstall,
|
|
166
|
+
upgradeInstruction,
|
|
167
|
+
buildUpdateNotice,
|
|
168
|
+
maybeAppendUpdateNotice,
|
|
169
|
+
guideUpdateLine,
|
|
170
|
+
_resetLatchForTests,
|
|
171
|
+
};
|
|
@@ -126,6 +126,7 @@ function normalizeLive(doc) {
|
|
|
126
126
|
stageName: active ? active.name : null,
|
|
127
127
|
stages,
|
|
128
128
|
seats: legRowsOf(doc).map(seatOf),
|
|
129
|
+
degrades: Array.isArray(doc.degrades) ? doc.degrades : [],
|
|
129
130
|
// ⚠️ v4.4.1 RN-8 (D1 ruling: delete the promise). `legsTotal`/`legsComplete` used to be
|
|
130
131
|
// mapped here under a comment promising them as "the honest fallback readout if a seat row
|
|
131
132
|
// is ever unavailable" — a UI fallback nobody ever wired. Nothing in electron/workspace-ui/
|