amicus 4.8.1 → 4.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +316 -0
- package/README.md +8 -3
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +54 -4
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +41 -18
- package/electron/main.js +19 -5
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +46 -39
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- 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 +1 -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 +1 -1
- 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 +63 -15
- 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-fallback.js +2 -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-render.js +71 -0
- package/src/sidecar/models.js +19 -45
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/reopen-spend.js +2 -1
- package/src/sidecar/setup.js +13 -4
- package/src/sidecar/start.js +2 -1
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +10 -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 +19 -9
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +2 -5
- 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 +21 -3
- package/src/utils/model-canonicalization.js +55 -6
- package/src/utils/model-catalog.js +27 -9
- package/src/utils/model-fetcher.js +69 -16
- package/src/utils/model-shortlist.js +5 -2
- package/src/utils/provider-default-picker.js +6 -3
- package/src/utils/quick-picks.js +45 -7
- package/src/utils/result-schema.js +17 -1
- 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 +33 -7
- 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, stripGatewayPrefix } = 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 (stripGatewayPrefix(local) === stripGatewayPrefix(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,10 +113,11 @@ 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
123
|
// names it, and this table is the FLOOR a runner falls back to when no
|
|
@@ -134,9 +142,11 @@ function getFamilies() {
|
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
145
|
+
* ⚠️ MECHANICAL primitive, NOT a routing decision (renamed from `toCanonicalDefault`,
|
|
146
|
+
* issue 214 — model-canonicalization.js explains why that name was a trap). An id that
|
|
147
|
+
* will be CALLED or STORED must come from directFormIfSafe/directFormIfProven. Strips
|
|
148
|
+
* the `openrouter/` prefix off a pinned route when `<vendor>` has a direct integration
|
|
149
|
+
* (provider-registry `isDirectProvider`), so the resulting bare
|
|
140
150
|
* `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
|
|
141
151
|
* direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
|
|
142
152
|
* integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
|
|
@@ -146,7 +156,7 @@ function getFamilies() {
|
|
|
146
156
|
* @param {string} route
|
|
147
157
|
* @returns {string}
|
|
148
158
|
*/
|
|
149
|
-
function
|
|
159
|
+
function stripGatewayPrefix(route) {
|
|
150
160
|
if (typeof route === 'string' && route.startsWith('openrouter/')) {
|
|
151
161
|
const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
|
|
152
162
|
const slashIdx = rest.indexOf('/');
|
|
@@ -203,7 +213,7 @@ function vendorOf(orRoute) {
|
|
|
203
213
|
function directFormFor(vendorPath, obj) {
|
|
204
214
|
if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
|
|
205
215
|
if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
|
|
206
|
-
const bare =
|
|
216
|
+
const bare = stripGatewayPrefix(obj.openrouter); // safe only when ids are identical across gateways
|
|
207
217
|
return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
|
|
208
218
|
}
|
|
209
219
|
|
|
@@ -285,6 +295,6 @@ function toDefaultAliases() {
|
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
module.exports = {
|
|
288
|
-
getFamilies, toDefaultAliases,
|
|
298
|
+
getFamilies, toDefaultAliases, stripGatewayPrefix, listCuratedRoutes, toGatewayRoutes,
|
|
289
299
|
directFormProvenance, DIVERGENT_VENDORS
|
|
290
300
|
};
|
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
|
}
|
|
@@ -34,11 +34,8 @@
|
|
|
34
34
|
|
|
35
35
|
const HINTS = require('./remediation-hints');
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
// imported to avoid a require cycle (cli-handlers-doctor.js requires this
|
|
40
|
-
// module at load time, before its own module.exports exists).
|
|
41
|
-
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
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;
|
|
42
39
|
|
|
43
40
|
/**
|
|
44
41
|
* @param {{fetchedAt?: number}|null} cache
|