amicus 4.8.0 → 4.9.0
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 +355 -0
- package/README.md +10 -5
- package/docs/CITATIONS.md +13 -5
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +55 -5
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +39 -2
- package/electron/main.js +46 -3
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +244 -32
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/live-seats.js +4 -4
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +2 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +12 -15
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +64 -16
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/setup.js +124 -0
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +16 -8
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +149 -0
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +10 -2
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +24 -0
- package/src/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +35 -9
- package/src/workspace/seat-space.js +10 -6
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias-shadow self-diagnosis — name a local alias that repoints a curated one.
|
|
3
|
+
*
|
|
4
|
+
* (v4.9 W13 Task B — BACKLOG C5, #129's own side observation.)
|
|
5
|
+
*
|
|
6
|
+
* A user-config alias may repoint a CURATED alias at a different model. When it
|
|
7
|
+
* does, everything keyed on the alias NAME — per-model operating notes, the
|
|
8
|
+
* bench a workflow spells, a council preset — quietly describes a model the
|
|
9
|
+
* alias no longer resolves to. Nothing downstream can recover the fact:
|
|
10
|
+
* `config.js :: getEffectiveAliases` returns `{...DEFAULT_ALIASES,
|
|
11
|
+
* ...userAliases}`, so by the time `resolveModel` or
|
|
12
|
+
* `route-launch.js :: resolveRouteForLaunch` sees an id, the two sources have
|
|
13
|
+
* already collapsed into one string. That is why the comparison lives here and
|
|
14
|
+
* reads the two sides SEPARATELY (`loadConfig().aliases` vs `toDefaultAliases()`)
|
|
15
|
+
* rather than being a line inside the merge.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ DIAGNOSIS ONLY. This module resolves nothing, changes no id, no exit code
|
|
18
|
+
* and no artifact — a local override winning over the curated pin is the
|
|
19
|
+
* DOCUMENTED contract (`getEffectiveAliases`: "user wins"). All this does is say
|
|
20
|
+
* so out loud, once, on stderr.
|
|
21
|
+
*
|
|
22
|
+
* Wired at three sites, all measured (see tests/alias-shadow.test.js's header
|
|
23
|
+
* for the measurement):
|
|
24
|
+
* 1. `cli-council-run-bench.js :: resolveBench` — the one bench-resolution
|
|
25
|
+
* helper BOTH council transports EXECUTE, because `mcp-council-run.js`
|
|
26
|
+
* always spawns the CLI child with an expanded `--models` list. The bench,
|
|
27
|
+
* the chair (explicit or default) and the critic are all inspected there:
|
|
28
|
+
* they resolve through this same table, so a shadow on any of them is
|
|
29
|
+
* equally invisible (PR #203 round 1, A6).
|
|
30
|
+
* 2. `mcp-council-bench.js :: auditBenchAliases`, called by
|
|
31
|
+
* `mcp-council-run.js :: handleCouncilRunTool` — the MCP SURFACE (PR #207
|
|
32
|
+
* round 2, A1). Site 1 executes on the MCP path but surfaces nothing there
|
|
33
|
+
* (see below); this one writes into the tool result's notice array, so the
|
|
34
|
+
* client actually sees it. Same three seats, same entry point.
|
|
35
|
+
* 3. `sidecar/models.js :: runCheck` — `amicus models --check`, the one site
|
|
36
|
+
* that passes no name list, so the subject is every CURATED alias the user
|
|
37
|
+
* has also configured (PR #207 round 3, B2 — the earlier wording here said
|
|
38
|
+
* "the whole configured alias set", which overstates it by the same margin
|
|
39
|
+
* the `findAliasShadows` docstring did).
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ SURFACE vs EXECUTION, measured (PR #203 round 1 A4; resolved PR #207 round
|
|
42
|
+
* 2 A1). Site 1 EXECUTES on both transports but SURFACES on only one: the
|
|
43
|
+
* council child's stderr is not a pipe the MCP server reads. `mcp-server.js ::
|
|
44
|
+
* spawnSidecarProcess` spawns it with `stdio: ['ignore', 'ignore', <fd>]`, the
|
|
45
|
+
* fd being an open handle on `<runDir>/debug.log` (or `'ignore'` when that dir
|
|
46
|
+
* cannot be created), then `unref`s it. So on the CLI the notice lands on the
|
|
47
|
+
* user's terminal; from site 1 alone an MCP caller would have to open
|
|
48
|
+
* `<runDir>/debug.log` to find it. Site 2 exists to close exactly that gap, and
|
|
49
|
+
* it is a different SURFACE rather than a second copy of the same one — the
|
|
50
|
+
* child still writes its line to `debug.log`, the parent writes its own to the
|
|
51
|
+
* tool result, and no single surface ever shows it twice. The same limitation
|
|
52
|
+
* still applies to every OTHER stderr notice the council child writes.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ THE WRITE HALF LIVES NEXT DOOR, in `alias-shadow-writer.js` (round 4). This
|
|
55
|
+
* file is the CHECK and the RENDERING; that one is `safeWrite`, `armStream` and
|
|
56
|
+
* the default stderr writer, with the measured Node pipe behaviour they defend
|
|
57
|
+
* against. Neither was ever exported from here, so the split changed no import
|
|
58
|
+
* path in the tree.
|
|
59
|
+
*
|
|
60
|
+
* Named mutants, all with their red sets recorded in tests/alias-shadow.test.js:
|
|
61
|
+
* "SHADOWSILENT" (make `noteAliasShadows` a no-op), "GATEWAYFORM" (compare raw
|
|
62
|
+
* strings instead of canonical forms below), "SCOPESTUCK" (give
|
|
63
|
+
* `auditAliasShadows` a shared module-global Set instead of a fresh one),
|
|
64
|
+
* "WRITERFATAL" (drop `safeWrite`'s try/catch — now in alias-shadow-writer.js),
|
|
65
|
+
* "MCPMUTE" (make `auditBenchAliases` a no-op), "STREAMFATAL" (drop
|
|
66
|
+
* `armStream`'s attach-once 'error' handler — round 3, A1, also next door),
|
|
67
|
+
* "MESSAGERAW" (interpolate `err.message` straight into the failure line again —
|
|
68
|
+
* round 3, B1), "NOTICERAW" (interpolate the config-sourced fragments into
|
|
69
|
+
* `formatAliasShadow`'s template unsanitized — round 4, A1), "THROWNRAW" (drop
|
|
70
|
+
* `describeThrown`'s sanitizing pass, leaving the FAILURE line raw and unbounded
|
|
71
|
+
* — round 5, C1) and "STREAMDEAF" (put `armStream`'s pure no-op handler back, so
|
|
72
|
+
* the arming goes deaf to every error class again — round 5, A3/B2/D1/C2, in
|
|
73
|
+
* alias-shadow-writer.js).
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
'use strict';
|
|
77
|
+
|
|
78
|
+
// The WRITE half, extracted in round 4 — `safeWrite` is the synchronous guard,
|
|
79
|
+
// `writeNoticeToStderr` the armed default writer. See that module for the
|
|
80
|
+
// measured Node behaviour both of them exist for.
|
|
81
|
+
const { safeWrite, writeNoticeToStderr } = require('./alias-shadow-writer');
|
|
82
|
+
// The house sanitizer for third-party text — see `formatAliasShadow` for which
|
|
83
|
+
// fragments are third-party and why the pass is per-fragment.
|
|
84
|
+
const { collapseExcerpt } = require('./text-sanitize');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Aliases whose LOCAL (user-config) value differs from the id amicus ships.
|
|
88
|
+
*
|
|
89
|
+
* @param {string[]} [names] alias names to inspect — a council bench, typically.
|
|
90
|
+
* Members carrying a `/` are full model ids, not aliases, and are skipped;
|
|
91
|
+
* members are trimmed and de-duplicated. Omit the argument to inspect every
|
|
92
|
+
* CURATED alias, reporting the ones the user has also configured (the
|
|
93
|
+
* `models --check` surface).
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ That is narrower than "every alias the user has configured", which is
|
|
96
|
+
* what this used to claim (PR #207 round 3, B2), and the narrower scope is the
|
|
97
|
+
* CORRECT one: a shadow is by definition a local value standing in front of a
|
|
98
|
+
* curated twin, so an alias the curated table never ships has nothing to
|
|
99
|
+
* shadow and can never appear here. The same holds for an explicit `names`
|
|
100
|
+
* list — a purely local member is silently skipped, not reported.
|
|
101
|
+
* @returns {Array<{alias: string, local: string, curated: string}>} one row per
|
|
102
|
+
* shadowed alias, in the order the names were given.
|
|
103
|
+
*/
|
|
104
|
+
function findAliasShadows(names) {
|
|
105
|
+
const { loadConfig } = require('./config');
|
|
106
|
+
const { toDefaultAliases, toCanonicalDefault } = require('./curated-models');
|
|
107
|
+
const cfg = loadConfig();
|
|
108
|
+
const userAliases = (cfg && cfg.aliases && typeof cfg.aliases === 'object') ? cfg.aliases : {};
|
|
109
|
+
// Own keys only: a user config.json can carry a literal `__proto__`/`toString`
|
|
110
|
+
// key, and bare indexing on a plain object would read Object.prototype — the
|
|
111
|
+
// same defect class `getEffectiveAliases`'s `__proto__: null` closed.
|
|
112
|
+
const configured = new Set(Object.keys(userAliases));
|
|
113
|
+
const curated = toDefaultAliases(); // already null-prototype; safe to index
|
|
114
|
+
const wanted = Array.isArray(names) ? names : Object.keys(curated);
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const raw of wanted) {
|
|
118
|
+
const alias = typeof raw === 'string' ? raw.trim() : '';
|
|
119
|
+
if (!alias || alias.includes('/') || seen.has(alias)) { continue; }
|
|
120
|
+
seen.add(alias);
|
|
121
|
+
if (!configured.has(alias)) { continue; } // no local override -> silent
|
|
122
|
+
const local = userAliases[alias];
|
|
123
|
+
const shipped = curated[alias];
|
|
124
|
+
if (typeof local !== 'string' || typeof shipped !== 'string') { continue; } // not a curated alias -> silent
|
|
125
|
+
// Compare CANONICAL forms, not raw strings. A direct-capable vendor's
|
|
126
|
+
// curated pin is the bare policy-routed id (`openai/gpt-5.6-terra`), so a
|
|
127
|
+
// config pinning the explicit OpenRouter form of the SAME MODEL differs as a
|
|
128
|
+
// string and not as a model — the same false positive
|
|
129
|
+
// `alias-drift.js`/`findDriftedStoredAliases` already guards against, and it
|
|
130
|
+
// would fire on gpt AND deepseek in this repo's own CI alias map. Gateway
|
|
131
|
+
// ROUTING has its own audit (`models --check`'s per-gateway section); this
|
|
132
|
+
// notice speaks only when the alias names a different MODEL. The rows still
|
|
133
|
+
// report both sides RAW, so the user can grep their own config.
|
|
134
|
+
if (toCanonicalDefault(local) === toCanonicalDefault(shipped)) { continue; }
|
|
135
|
+
out.push({ alias, local, curated: shipped });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* How much of one quoted config fragment ever reaches the line. MEASURED: the
|
|
142
|
+
* longest id the curated table ships is 39 characters
|
|
143
|
+
* (`openrouter/mistralai/mistral-medium-3-5`) and the longest alias name is 10;
|
|
144
|
+
* past 64 this is not a model id, it is a payload.
|
|
145
|
+
*/
|
|
146
|
+
const MAX_FRAGMENT_CHARS = 64;
|
|
147
|
+
|
|
148
|
+
/** One quoted config fragment, safe to paste into the line below. */
|
|
149
|
+
const safeFragment = (value) => collapseExcerpt(value, MAX_FRAGMENT_CHARS);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The notice, with everything it QUOTES neutralized (PR #207 round 4, A1).
|
|
153
|
+
*
|
|
154
|
+
* This line quotes the user's `config.json` onto a terminal and into the MCP
|
|
155
|
+
* tool result, so each quoted value is third-party text and rides the house
|
|
156
|
+
* sanitizer — `utils/text-sanitize.js :: collapseExcerpt`, the same one
|
|
157
|
+
* `engine-skew.js :: safeVersion` uses at its own cap, for the same reason. ANSI
|
|
158
|
+
* and bidi controls are dropped, remaining control bytes collapse to spaces, and
|
|
159
|
+
* the result is one bounded line: a config value can no longer repaint the
|
|
160
|
+
* terminal, forge a SECOND `Notice:` line after the newline it smuggled in, or
|
|
161
|
+
* reverse the sentence it is quoted into.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ THE FRAGMENTS, NOT THE COMPOSED LINE. `collapseExcerpt` trims and caps
|
|
164
|
+
* whatever it is given, so passing the finished string would eat the trailing
|
|
165
|
+
* newline both writers depend on and could clip `(curated ships …)` off the end.
|
|
166
|
+
* The quotes, the parens and that newline are OURS; only the values are theirs.
|
|
167
|
+
*
|
|
168
|
+
* ⚠️ MEASURED, which fragment is actually hostile-capable: `local` is a raw
|
|
169
|
+
* config VALUE and is the hole. `alias` is user-supplied too, but a row exists
|
|
170
|
+
* only when the name is byte-identical to a key of the null-prototype curated
|
|
171
|
+
* table, and all 21 shipped names measure `/^[a-z0-9.-]+$/` — an escape-carrying
|
|
172
|
+
* name never becomes a row at all (pinned as an absence control). `curated` is
|
|
173
|
+
* house data. All three go through anyway: "this notice is one line, and its
|
|
174
|
+
* structure is ours" should not depend on that chain of reasoning surviving the
|
|
175
|
+
* next change to the curated table.
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ The ROWS `findAliasShadows` returns stay RAW on purpose (see its docstring)
|
|
178
|
+
* — this is a RENDERING pass, and a caller diffing a row against the config file
|
|
179
|
+
* still sees the bytes that are actually in it.
|
|
180
|
+
* @param {{alias: string, local: string, curated: string}} s
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
function formatAliasShadow(s) {
|
|
184
|
+
return `Notice: alias '${safeFragment(s.alias)}' resolves to ${safeFragment(s.local)} `
|
|
185
|
+
+ `(curated ships ${safeFragment(s.curated)})\n`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Describe a caught throw without becoming the next one (PR #207 round 3, B1).
|
|
190
|
+
*
|
|
191
|
+
* The failure branch below used to interpolate `err.message` directly, so the
|
|
192
|
+
* template was evaluated BEFORE `safeWrite` ever ran: a thrown `null` or
|
|
193
|
+
* `undefined` raised a fresh TypeError inside the catch and escaped
|
|
194
|
+
* `noteAliasShadows` entirely — the exact failure the catch exists to prevent,
|
|
195
|
+
* re-entering through the catch's own announcement. A thrown bare string was the
|
|
196
|
+
* quieter half: strings carry no `.message`, so a real reason printed as
|
|
197
|
+
* `(undefined)`.
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ `String(x)` is not total either — MEASURED: `String(Object.create(null))`
|
|
200
|
+
* throws `TypeError: Cannot convert object to primitive value` — so the
|
|
201
|
+
* conversion carries its own guard. Nothing in a catch block may throw,
|
|
202
|
+
* including the code that describes what was caught.
|
|
203
|
+
*
|
|
204
|
+
* ⚠️ AND IT IS THIRD-PARTY TEXT (PR #207 round 5, C1). Round 3 made this total;
|
|
205
|
+
* round 4 sanitized the SHADOW notice's fragments and left this one's result
|
|
206
|
+
* raw, so the FAILURE line still pasted an arbitrary thrown message —
|
|
207
|
+
* unsanitized and unbounded — onto a terminal and into an MCP tool result. A
|
|
208
|
+
* thrown value is not house data: it carries provider text, a filesystem path,
|
|
209
|
+
* or the user's own config file (the case that motivated round 4). Same
|
|
210
|
+
* `collapseExcerpt` pass, same reasons, one function later.
|
|
211
|
+
*
|
|
212
|
+
* ⚠️ THE CAP IS THE HOUSE DEFAULT (200), NOT `MAX_FRAGMENT_CHARS` (64). Both are
|
|
213
|
+
* the same discipline — a caller sizing the cap to what its field legitimately
|
|
214
|
+
* holds, as `engine-skew.js :: safeVersion` does at 32 — and the two fields are
|
|
215
|
+
* not the same kind of text. A fragment is a MODEL ID (measured longest: 39), so
|
|
216
|
+
* 64 bounds it with room to spare. A thrown message is a SENTENCE: `EACCES:
|
|
217
|
+
* permission denied, open 'C:\\Users\\…\\config.json'` already exceeds 64, and
|
|
218
|
+
* clipping there would bound the payload by destroying the diagnosis. 200 is
|
|
219
|
+
* what `text-sanitize.js` documents as "long enough for a real engine error",
|
|
220
|
+
* and it leaves this composed line the same order of magnitude as the shadow
|
|
221
|
+
* line's three 64-char fragments.
|
|
222
|
+
*
|
|
223
|
+
* The pass runs INSIDE the try, so a value whose `String()` throws still lands
|
|
224
|
+
* in the catch rather than escaping through the sanitizer's argument.
|
|
225
|
+
* @param {*} err whatever was thrown — an Error, a string, null, anything.
|
|
226
|
+
* @returns {string} one bounded line, safe to paste into the notice.
|
|
227
|
+
*/
|
|
228
|
+
function describeThrown(err) {
|
|
229
|
+
try { return collapseExcerpt(String((err && err.message) || err)); }
|
|
230
|
+
catch { return 'unprintable error'; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Say it once per SCOPE. Never throws — not the check, not the write, and (round
|
|
235
|
+
* 3, A1) not the write's own delayed failure either.
|
|
236
|
+
*
|
|
237
|
+
* It never degrades SILENTLY either: a failed check announces its own failure.
|
|
238
|
+
* That is not defensive decoration. During this task's own development the
|
|
239
|
+
* guard swallowed a real `TypeError: loadConfig is not a function` (a leaked
|
|
240
|
+
* module mock in tests/sidecar/models-command.test.js) and the feature simply
|
|
241
|
+
* went quiet, which is precisely the correct-but-silent degrade the product
|
|
242
|
+
* principle forbids.
|
|
243
|
+
*
|
|
244
|
+
* ⚠️ The dedup scope is the caller's, passed in (PR #207 council round 2, B3).
|
|
245
|
+
* It used to be a module-global `spoken` Set that `auditAliasShadows` cleared
|
|
246
|
+
* wholesale — so one caller's dedup state was reachable, and erasable, by every
|
|
247
|
+
* other caller in the process. There is no module-global state here now: two
|
|
248
|
+
* scope-less calls are two independent scopes.
|
|
249
|
+
* @param {string[]} [names] see findAliasShadows
|
|
250
|
+
* @param {(line: string) => void} [write] injected for tests; defaults to stderr
|
|
251
|
+
* so a `--json` document on stdout stays byte-clean.
|
|
252
|
+
* @param {Set<string>} [scope] alias names already spoken. Omit for a fresh
|
|
253
|
+
* one-shot scope; pass a shared Set to collapse several calls into one run.
|
|
254
|
+
*/
|
|
255
|
+
function noteAliasShadows(names, write, scope) {
|
|
256
|
+
const out = write || writeNoticeToStderr;
|
|
257
|
+
const spoken = scope instanceof Set ? scope : new Set();
|
|
258
|
+
try {
|
|
259
|
+
for (const s of findAliasShadows(names)) {
|
|
260
|
+
if (spoken.has(s.alias)) { continue; }
|
|
261
|
+
spoken.add(s.alias);
|
|
262
|
+
safeWrite(out, formatAliasShadow(s));
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
// `describeThrown` FIRST, and outside the template, so the announcement
|
|
266
|
+
// cannot become the second escape (round 3, B1).
|
|
267
|
+
const why = describeThrown(err);
|
|
268
|
+
safeWrite(out, `Notice: could not check whether local aliases shadow the curated table (${why})\n`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* THE WIRING ENTRY POINT: open a fresh notice scope, then speak.
|
|
274
|
+
*
|
|
275
|
+
* Every wired site calls this and nothing else, in ONE statement, so the scope
|
|
276
|
+
* can never be opened by one caller and forgotten by the next — a two-step
|
|
277
|
+
* "reset, then note" would have made the A5 defect re-introducible by omission,
|
|
278
|
+
* which for a self-diagnosis feature means going quiet with a green suite.
|
|
279
|
+
*
|
|
280
|
+
* PR #203 A5 asked for a re-openable scope; round 2 (B3) supplied the shape
|
|
281
|
+
* that makes it structural rather than disciplined: the fresh `Set` IS the
|
|
282
|
+
* scope, so there is no shared latch to get stuck and none to clear.
|
|
283
|
+
* `noteAliasShadows` stays exported as the scope-respecting primitive (two
|
|
284
|
+
* calls sharing one scope still produce one line per alias).
|
|
285
|
+
* @param {string[]} [names] see findAliasShadows
|
|
286
|
+
* @param {(line: string) => void} [write] see noteAliasShadows
|
|
287
|
+
*/
|
|
288
|
+
function auditAliasShadows(names, write) {
|
|
289
|
+
noteAliasShadows(names, write, new Set());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
findAliasShadows, formatAliasShadow, noteAliasShadows, auditAliasShadows,
|
|
294
|
+
};
|
package/src/utils/config.js
CHANGED
|
@@ -244,7 +244,7 @@ function getEffectiveAliases() {
|
|
|
244
244
|
// The SAME defect class this release already closed at other lookup tables —
|
|
245
245
|
// `tally.js :: VERDICTS`, `report.js :: SYMBOL`, `debate.js :: PAST_TENSE`
|
|
246
246
|
// (all `__proto__: null`), plus `street-cred.js :: perJudgeRank` and
|
|
247
|
-
// `report.js :: ROLE_SUFFIX` (both `Object.create(null)`, the same guarantee
|
|
247
|
+
// `report-cost.js :: ROLE_SUFFIX` (both `Object.create(null)`, the same guarantee
|
|
248
248
|
// in the other spelling). The ALIAS table was not among them.
|
|
249
249
|
// ⚠️ SI-22.4 WIDENED it and that is why it is fixed here: at BASE the padded
|
|
250
250
|
// spelling ('toString ') missed the prototype and was correctly dropped;
|
|
@@ -98,7 +98,14 @@ const CARDLESS = [
|
|
|
98
98
|
// (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
|
|
99
99
|
{ alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
|
|
100
100
|
anthropic: 'anthropic/claude-fable-5' } },
|
|
101
|
-
|
|
101
|
+
// qwen/kimi refreshed 2026-08-26 (v4.9 W13): both were a model generation
|
|
102
|
+
// behind. These are the FALLBACK FLOOR — a caller or fork with no CI alias map
|
|
103
|
+
// resolves its whole bench through this table (see `inkling` below), while the
|
|
104
|
+
// owner's machine and .github/amicus-ci-aliases.json already ran the newer ids.
|
|
105
|
+
// Cardless entries have no `idPattern`, so `models --check` can only ask
|
|
106
|
+
// whether the OLD id still EXISTS — which is how a pin sits a generation back
|
|
107
|
+
// with every gate green (scripts/check-ci-alias-pins.js asks the other one).
|
|
108
|
+
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max' } },
|
|
102
109
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
103
110
|
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
104
111
|
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
|
@@ -106,17 +113,18 @@ const CARDLESS = [
|
|
|
106
113
|
// whole devstral family and the alias had no other route. No retarget — no
|
|
107
114
|
// served model is a devstral successor ("no pinned guess is better than a
|
|
108
115
|
// wrong one"); `mistral` remains the vendor's alias.
|
|
109
|
-
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.
|
|
116
|
+
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.3' } },
|
|
110
117
|
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.7' } },
|
|
111
118
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
112
|
-
|
|
119
|
+
// kimi: see the qwen refresh note above (both moved 2026-08-26, v4.9 W13).
|
|
120
|
+
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k3' } },
|
|
113
121
|
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
|
|
114
122
|
// inkling added 2026-08-14: the council-review workflow's default bench
|
|
115
|
-
// names it, and
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// seat wants the flagship's
|
|
119
|
-
//
|
|
123
|
+
// names it, and this table is the FLOOR a runner falls back to when no
|
|
124
|
+
// alias map is provisioned (workflow_call callers, forks) — there, a
|
|
125
|
+
// locally defined alias still resolves to nothing. Pinned to the full
|
|
126
|
+
// model, not `inkling-small`: the bench seat wants the flagship's
|
|
127
|
+
// judgment. `:batch` is not pinned (wrong for an interactive council leg).
|
|
120
128
|
{ alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
|
|
121
129
|
];
|
|
122
130
|
|
package/src/utils/degrade.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @module utils/degrade
|
|
5
|
-
* The degrade/heal record: one shape, one vocabulary, shared by the
|
|
6
|
-
* runtime and `doctor`. Pure — no I/O, no council knowledge.
|
|
5
|
+
* The degrade/heal/info record: one shape, one vocabulary, shared by the
|
|
6
|
+
* council runtime and `doctor`. Pure — no I/O, no council knowledge.
|
|
7
7
|
*
|
|
8
8
|
* WHY validation lives here and THROWS: it is what makes the announcement
|
|
9
9
|
* contract real. A degrade that does not say what was lost, why, and what it
|
|
@@ -17,6 +17,8 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
19
|
'stage1-retry',
|
|
20
|
+
// v4.9 task mode: a task run writes no reliability-ledger rows — announced as kind:'info'.
|
|
21
|
+
'ledger-skipped',
|
|
20
22
|
// v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
|
|
21
23
|
// returned legs but none its own; a returned leg matching no roster slot; and (T5.5, `-rv` only)
|
|
22
24
|
// a leg that DID match a slot but whose join key names no judge the wave launched.
|
|
@@ -27,13 +29,16 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
27
29
|
'doctor-check-failed', 'doctor-fix',
|
|
28
30
|
]));
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
// 'info' (v4.9): an announcement that is neither a loss nor a recovery — the
|
|
33
|
+
// sink records and prints it but never flips `degraded` (run-degrade.js gates
|
|
34
|
+
// the flip on kind === 'degrade').
|
|
35
|
+
const KINDS = Object.freeze(new Set(['degrade', 'heal', 'info']));
|
|
31
36
|
const REQUIRED = ['what', 'why', 'effect'];
|
|
32
37
|
|
|
33
38
|
function makeDegrade(input = {}) {
|
|
34
39
|
const kind = input.kind === undefined ? 'degrade' : input.kind;
|
|
35
40
|
if (!KINDS.has(kind)) {
|
|
36
|
-
throw new Error(`degrade: unknown kind '${kind}' (expected 'degrade' or '
|
|
41
|
+
throw new Error(`degrade: unknown kind '${kind}' (expected 'degrade', 'heal', or 'info')`);
|
|
37
42
|
}
|
|
38
43
|
if (!DEGRADE_CHANNELS.has(input.channel)) {
|
|
39
44
|
throw new Error(`degrade: unknown channel '${input.channel}'`);
|
|
@@ -66,7 +71,9 @@ function makeDegrade(input = {}) {
|
|
|
66
71
|
* @returns {string} one line, newline-terminated
|
|
67
72
|
*/
|
|
68
73
|
function formatDegrade(record) {
|
|
69
|
-
|
|
74
|
+
// Lead map: degrade → Notice, heal → Recovered, info → Note. Anything else
|
|
75
|
+
// (a legacy JSON-parsed record with no kind) keeps the pre-info default, Notice.
|
|
76
|
+
const lead = record.kind === 'heal' ? 'Recovered' : record.kind === 'info' ? 'Note' : 'Notice';
|
|
70
77
|
const remedy = record.remedy ? ` Try: ${record.remedy}.` : '';
|
|
71
78
|
return `${lead}: ${record.what} — ${record.why}. ${record.effect}.${remedy}\n`;
|
|
72
79
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/utils/doctor-alias-check.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/doctor-alias-check
|
|
6
|
+
* The `aliases` doctor check ("Model aliases"), split out of
|
|
7
|
+
* src/cli-handlers-doctor.js to keep that file under the 300-line gate
|
|
8
|
+
* (mirrors doctor-engine-check.js / doctor-electron-mcp-check.js /
|
|
9
|
+
* doctor-base-url-check.js / doctor-local-providers-check.js -- same reason,
|
|
10
|
+
* a different check).
|
|
11
|
+
*
|
|
12
|
+
* B3 (council review of PR 198, issue 195): `doctor --fix` repairs exactly
|
|
13
|
+
* one narrow class of stored alias -- see alias-audit.js's
|
|
14
|
+
* `findFabricatedAliasRepairs` for the detection rule (classifies `invalid`
|
|
15
|
+
* on the `direct` gateway AND has an unambiguous OpenRouter twin) and why it
|
|
16
|
+
* cannot false-positive a typo, a retired model, or a user-invented id.
|
|
17
|
+
* Repair = rewrite `config.aliases[alias]` to that catalog-confirmed
|
|
18
|
+
* OpenRouter id -- read-modify-write, no-clobber (mirrors
|
|
19
|
+
* `applyProviderDefault`, provider-default-picker.js). Every OTHER
|
|
20
|
+
* stale/drifted alias is left untouched and stays a warning, hinting at
|
|
21
|
+
* `amicus models --check` same as before this PR.
|
|
22
|
+
*
|
|
23
|
+
* A3 (council review of PR 198): the repair ACTION additionally requires the
|
|
24
|
+
* cached catalog to be FRESH (same `MAX_CATALOG_AGE_MS` window as doctor's
|
|
25
|
+
* own `catalog` check, cli-handlers-doctor.js). `readCache()` here reads the
|
|
26
|
+
* exact same cache doctor's `catalog` check may independently report as
|
|
27
|
+
* `stale (Nh old)` -- without this gate, `--fix` would rewrite a user's
|
|
28
|
+
* config from data the SAME run just called untrustworthy. A stale catalog
|
|
29
|
+
* can be missing rows that would make a "fabricated" id look repairable when
|
|
30
|
+
* it is merely unfetched, so a stale catalog declines the repair (explaining
|
|
31
|
+
* why via `repairFabricatedAliasStaleCatalog`) rather than writing on
|
|
32
|
+
* unverified evidence; detection/reporting is unaffected either way.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const HINTS = require('./remediation-hints');
|
|
36
|
+
|
|
37
|
+
// Single source: model-catalog.js's DEFAULT_MAX_AGE_MS -- the same 24h window as doctor's own `catalog` check.
|
|
38
|
+
const MAX_CATALOG_AGE_MS = require('./model-catalog').DEFAULT_MAX_AGE_MS;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{fetchedAt?: number}|null} cache
|
|
42
|
+
* @returns {boolean} true when `cache` exists, has a numeric `fetchedAt`, and
|
|
43
|
+
* is no older than `MAX_CATALOG_AGE_MS` -- the same test doctor's `catalog`
|
|
44
|
+
* check applies to decide `ok` vs `stale (Nh old)`.
|
|
45
|
+
*/
|
|
46
|
+
function isCatalogFresh(cache) {
|
|
47
|
+
if (!cache || typeof cache.fetchedAt !== 'number') { return false; }
|
|
48
|
+
return (Date.now() - cache.fetchedAt) <= MAX_CATALOG_AGE_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rewrite one alias's stored value in place. Read-modify-write / no-clobber
|
|
53
|
+
* -- preserves `config.default` and every other alias/key (same contract as
|
|
54
|
+
* `applyProviderDefault`, provider-default-picker.js).
|
|
55
|
+
* @param {string} alias
|
|
56
|
+
* @param {string} newId verbatim catalog id (an OpenRouter-namespace id from
|
|
57
|
+
* `pairAcrossGateways` -- never hand-derived by string concatenation)
|
|
58
|
+
*/
|
|
59
|
+
function repairAlias(alias, newId) {
|
|
60
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
61
|
+
const config = loadConfig() || {};
|
|
62
|
+
if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
|
|
63
|
+
config.aliases[alias] = newId;
|
|
64
|
+
saveConfig(config);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One pass: sources + both existing audits + the repairable set, over the same catalog. */
|
|
68
|
+
function computeState(d, catalog) {
|
|
69
|
+
const sources = d.collectAliasSources();
|
|
70
|
+
return {
|
|
71
|
+
sources,
|
|
72
|
+
stale: d.findStaleAliases(sources, catalog),
|
|
73
|
+
drifted: d.findDriftedStoredAliases(sources, catalog),
|
|
74
|
+
repairable: d.findFabricatedAliasRepairs(sources, catalog),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{readCache: () => ({models?: Array}|null), collectAliasSources: () => Array,
|
|
80
|
+
* findStaleAliases: (s:Array, c:Array) => Array, findDriftedStoredAliases: (s:Array, c:Array) => Array,
|
|
81
|
+
* findFabricatedAliasRepairs: (s:Array, c:Array) => Array<{alias:string,oldId:string,newId:string}>,
|
|
82
|
+
* fix?: boolean, repairAlias?: (alias:string, newId:string) => void}} d
|
|
83
|
+
* @returns {{id,name,status,message,hint,fixed?,fixDetail?}}
|
|
84
|
+
*/
|
|
85
|
+
function evaluateAliasesCheck(d) {
|
|
86
|
+
const id = 'aliases';
|
|
87
|
+
const name = 'Model aliases';
|
|
88
|
+
const cache = d.readCache();
|
|
89
|
+
const catalog = (cache && cache.models) || [];
|
|
90
|
+
const catalogFresh = isCatalogFresh(cache);
|
|
91
|
+
|
|
92
|
+
let state = computeState(d, catalog);
|
|
93
|
+
let fixFields = {};
|
|
94
|
+
|
|
95
|
+
// Only under --fix, only when there is something in the narrow,
|
|
96
|
+
// mechanically-unambiguous class to repair (rule 1), and only on a FRESH
|
|
97
|
+
// catalog (A3) -- a failed individual rewrite is best-effort -- it simply
|
|
98
|
+
// stays a warning, same as one findStaleAliases could never resolve.
|
|
99
|
+
if (d.fix && state.repairable.length > 0 && catalogFresh) {
|
|
100
|
+
const repaired = [];
|
|
101
|
+
for (const r of state.repairable) {
|
|
102
|
+
try { d.repairAlias(r.alias, r.newId); repaired.push(r); }
|
|
103
|
+
catch { /* best-effort -- an unrepaired alias just stays a warning below */ }
|
|
104
|
+
}
|
|
105
|
+
if (repaired.length > 0) {
|
|
106
|
+
// Rule 6: announce every repair, naming the alias and both ids -- this
|
|
107
|
+
// fixDetail flows into the 'heal' degrade's `why` field (doctor-degrade.js).
|
|
108
|
+
const detail = repaired.map((r) => `'${r.alias}' (${r.oldId} -> ${r.newId})`).join('; ');
|
|
109
|
+
fixFields = {
|
|
110
|
+
fixed: true,
|
|
111
|
+
fixDetail: `rewrote ${repaired.length} fabricated alias(es) to its catalog-confirmed OpenRouter id: ${detail}`,
|
|
112
|
+
};
|
|
113
|
+
// Rule 5 (idempotency): recompute from a fresh config read so both this
|
|
114
|
+
// run's message and a second --fix run see the post-repair reality, not
|
|
115
|
+
// the pre-repair snapshot -- a repaired alias must not still count as
|
|
116
|
+
// stale/repairable below.
|
|
117
|
+
state = computeState(d, catalog);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { stale, drifted, repairable } = state;
|
|
122
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
id, name, status: 'ok',
|
|
125
|
+
message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null,
|
|
126
|
+
...fixFields,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const parts = [];
|
|
130
|
+
if (stale.length) { parts.push(`${stale.length} stale: ${stale.map((s) => s.alias).join(', ')}`); }
|
|
131
|
+
if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map((s) => s.alias).join(', ')}`); }
|
|
132
|
+
// Rule 1: without --fix, report the repairable count and the hint, change
|
|
133
|
+
// nothing. A3: when the catalog is stale, say so explicitly rather than
|
|
134
|
+
// offering a fix that will silently decline to write.
|
|
135
|
+
if (repairable.length) {
|
|
136
|
+
parts.push(catalogFresh
|
|
137
|
+
? `${repairable.length} fixable via doctor --fix`
|
|
138
|
+
: `${repairable.length} fixable via doctor --fix once the catalog is refreshed (catalog is stale)`);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
id, name, status: 'warn', message: parts.join('; '),
|
|
142
|
+
hint: repairable.length === 0
|
|
143
|
+
? 'amicus models --check'
|
|
144
|
+
: (catalogFresh ? HINTS.repairFabricatedAlias : HINTS.repairFabricatedAliasStaleCatalog),
|
|
145
|
+
...fixFields,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { evaluateAliasesCheck, repairAlias };
|