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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +128 -0
  3. package/README.md +2 -2
  4. package/docs/ROADMAP.md +45 -8
  5. package/docs/configuration.md +13 -9
  6. package/docs/council.md +7 -2
  7. package/docs/publishing.md +1 -1
  8. package/docs/troubleshooting.md +49 -20
  9. package/docs/usage.md +21 -1
  10. package/electron/setup-ui-aliases.js +2 -2
  11. package/electron/workspace-ui/index.html +3 -0
  12. package/electron/workspace-ui/live-model.js +71 -0
  13. package/electron/workspace-ui/workspace-app.js +2 -2
  14. package/electron/workspace-ui/workspace-panels.js +9 -10
  15. package/electron/workspace-ui/workspace-seats.js +117 -0
  16. package/electron/workspace-ui/workspace-verbs.js +1 -0
  17. package/electron/workspace-ui/workspace.css +6 -0
  18. package/package.json +1 -1
  19. package/schemas/alias-audit.schema.json +6 -1
  20. package/schemas/council-run.schema.json +14 -0
  21. package/skills/second-opinion/MODEL-NOTES.md +182 -35
  22. package/src/cli-handlers-doctor.js +16 -4
  23. package/src/cli.js +4 -0
  24. package/src/council/run-chair.js +49 -3
  25. package/src/council/run-launch.js +4 -0
  26. package/src/council/run-retry-notes.js +74 -0
  27. package/src/council/run-retry.js +280 -0
  28. package/src/council/run-stages.js +41 -11
  29. package/src/council/verdict.js +8 -1
  30. package/src/headless.js +119 -9
  31. package/src/mcp-council-awareness.js +1 -0
  32. package/src/mcp-server.js +17 -2
  33. package/src/mcp-tools.js +5 -1
  34. package/src/opencode-client.js +21 -0
  35. package/src/sidecar/fanout-leg.js +2 -2
  36. package/src/sidecar/fanout.js +1 -1
  37. package/src/sidecar/models-probe.js +119 -0
  38. package/src/sidecar/models.js +81 -6
  39. package/src/utils/alias-audit.js +52 -1
  40. package/src/utils/base-url-classify.js +74 -0
  41. package/src/utils/council-presets.js +6 -2
  42. package/src/utils/curated-models.js +29 -10
  43. package/src/utils/degrade.js +1 -0
  44. package/src/utils/doctor-base-url-check.js +41 -0
  45. package/src/utils/model-fetcher.js +1 -0
  46. package/src/utils/model-tiers.js +28 -7
  47. package/src/utils/no-output-backstop.js +48 -0
  48. package/src/utils/remediation-hints.js +15 -11
  49. package/src/utils/result-schema.js +29 -2
  50. package/src/utils/update-notice.js +171 -0
  51. 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.5-pro $0.00003 / $0.00018
46
- * opus openrouter/anthropic/claude-opus-4.8 $0.000005 / $0.000025
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 any plain numeric flagship id
25
- * (gpt-5.5, gpt-6) and excludes suffixed variants (-pro/-mini/-codex).
26
- * Pinned ids verified against the live catalog 2026-06-24.
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.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
- fallback: { openrouter: 'openrouter/anthropic/claude-opus-4.8',
50
- anthropic: 'anthropic/claude-opus-4-8' } },
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-06-11.
72
+ * Refreshed against the live catalog 2026-08-04.
62
73
  */
63
74
  const CARDLESS = [
64
- { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.5-pro' } },
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
- { alias: 'devstral', routes: { openrouter: 'openrouter/mistralai/devstral-2512' } },
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' } },
@@ -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 },
@@ -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
- /** Tier regex table: pattern matches the model segment after `<vendor>/` (or `openrouter/<vendor>/`). */
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
- economy: /^gpt-[\d.]+-mini$/,
40
- balanced: /^gpt-[\d.]+$/,
41
- frontier: /^gpt-[\d.]+-pro$/,
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
- /** Newest catalog id matching `regex` under `vendor`; direct namespace preferred over OpenRouter's. */
81
- function pickForTier(catalog, vendor, regex) {
82
- return pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
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). This is the convergence
64
- * target for the three "reinstall to fix Electron" hints — it provisions the
65
- * binary from cache (or downloads on demand) WITHOUT a global reinstall, so it
66
- * can't loop the way `npm install -g amicus` could when the rollback recurs.
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?}>}} opts
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({ stale, catalogAvailable, gatewayFindings = [] }) {
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/