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.
Files changed (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +10 -5
  4. package/docs/CITATIONS.md +13 -5
  5. package/docs/ROADMAP.md +101 -10
  6. package/docs/configuration.md +55 -5
  7. package/docs/council.md +102 -14
  8. package/docs/troubleshooting.md +9 -2
  9. package/docs/usage.md +128 -12
  10. package/electron/ipc-setup.js +39 -2
  11. package/electron/main.js +46 -3
  12. package/electron/offer-session.js +51 -0
  13. package/electron/setup-ui-model.js +99 -9
  14. package/electron/setup-ui-styles.js +22 -0
  15. package/electron/setup-ui.js +244 -32
  16. package/electron/workspace-ui/live-dead-seats.js +163 -91
  17. package/electron/workspace-ui/live-seats.js +4 -4
  18. package/electron/workspace-ui/workspace-banners.js +30 -7
  19. package/electron/workspace-ui/workspace-matrix.js +23 -3
  20. package/electron/workspace-ui/workspace-seats.js +95 -79
  21. package/package.json +2 -1
  22. package/schemas/council-run.schema.json +2 -2
  23. package/schemas/council-tally.schema.json +17 -1
  24. package/schemas/council-verdict.schema.json +12 -4
  25. package/schemas/run.schema.json +6 -1
  26. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  27. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  28. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  29. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  30. package/skills/second-opinion/SKILL.md +151 -36
  31. package/src/cli-council-run-bench.js +98 -6
  32. package/src/cli-handlers-council-run.js +18 -6
  33. package/src/cli-handlers-council.js +57 -7
  34. package/src/cli-handlers-doctor.js +12 -15
  35. package/src/cli.js +3 -1
  36. package/src/council/anonymize.js +2 -1
  37. package/src/council/briefings-chair-task.js +161 -0
  38. package/src/council/briefings-chair.js +33 -8
  39. package/src/council/briefings-debate.js +79 -13
  40. package/src/council/briefings-stage2-task.js +236 -0
  41. package/src/council/briefings-stage2.js +103 -26
  42. package/src/council/briefings-task.js +167 -0
  43. package/src/council/briefings.js +41 -4
  44. package/src/council/chair-fallback.js +95 -0
  45. package/src/council/debate.js +38 -21
  46. package/src/council/findings.js +3 -2
  47. package/src/council/ledger.js +2 -2
  48. package/src/council/parse-stage2.js +64 -16
  49. package/src/council/report-cost.js +61 -0
  50. package/src/council/report-html.js +26 -4
  51. package/src/council/report-md.js +30 -2
  52. package/src/council/report.js +40 -37
  53. package/src/council/run-assemble.js +21 -6
  54. package/src/council/run-chair.js +44 -95
  55. package/src/council/run-debate-revote.js +81 -49
  56. package/src/council/run-debate.js +51 -34
  57. package/src/council/run-finish.js +5 -3
  58. package/src/council/run-retry-keys.js +4 -4
  59. package/src/council/run-retry-launch.js +4 -4
  60. package/src/council/run-retry-notes.js +72 -15
  61. package/src/council/run-stage1-launch.js +4 -4
  62. package/src/council/run-stage1-rows.js +9 -6
  63. package/src/council/run-stage2.js +81 -47
  64. package/src/council/run-stages.js +9 -21
  65. package/src/council/run-stats-entry.js +46 -1
  66. package/src/council/run.js +28 -13
  67. package/src/council/seats.js +2 -2
  68. package/src/council/stage1-bind.js +3 -2
  69. package/src/council/verdict-seat-loss.js +124 -0
  70. package/src/council/verdict.js +108 -99
  71. package/src/headless.js +256 -49
  72. package/src/mcp-council-bench.js +64 -3
  73. package/src/mcp-council-run.js +10 -3
  74. package/src/mcp-server.js +52 -12
  75. package/src/mcp-tools.js +41 -5
  76. package/src/observe/council-legs.js +2 -2
  77. package/src/opencode-client.js +19 -1
  78. package/src/pack/pack-forward.js +15 -12
  79. package/src/pack/pack-resolve.js +1 -1
  80. package/src/prompt-builder.js +17 -1
  81. package/src/sidecar/fanout-leg.js +26 -0
  82. package/src/sidecar/fanout.js +1 -1
  83. package/src/sidecar/list-council.js +178 -0
  84. package/src/sidecar/list-limit.js +3 -1
  85. package/src/sidecar/list-search.js +2 -1
  86. package/src/sidecar/models.js +8 -1
  87. package/src/sidecar/read.js +34 -10
  88. package/src/sidecar/setup.js +124 -0
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +81 -3
  91. package/src/utils/alias-shadow-writer.js +220 -0
  92. package/src/utils/alias-shadow.js +294 -0
  93. package/src/utils/config.js +1 -1
  94. package/src/utils/curated-models.js +16 -8
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +149 -0
  97. package/src/utils/engine-log-parse.js +289 -0
  98. package/src/utils/engine-log-tail.js +114 -0
  99. package/src/utils/engine-log.js +250 -0
  100. package/src/utils/engine-skew-records.js +146 -0
  101. package/src/utils/engine-skew.js +300 -0
  102. package/src/utils/gateway-router.js +10 -2
  103. package/src/utils/model-canonicalization.js +64 -0
  104. package/src/utils/model-catalog.js +1 -1
  105. package/src/utils/model-shortlist.js +100 -0
  106. package/src/utils/provider-default-picker.js +93 -45
  107. package/src/utils/provider-default-prompt.js +1 -1
  108. package/src/utils/quick-picks.js +2 -2
  109. package/src/utils/remediation-hints.js +24 -0
  110. package/src/utils/result-schema.js +10 -0
  111. package/src/utils/text-sanitize.js +81 -0
  112. package/src/utils/ttft.js +57 -0
  113. package/src/utils/untrusted-fence.js +111 -1
  114. package/src/workspace/fold-format.js +28 -7
  115. package/src/workspace/live-normalize.js +2 -1
  116. package/src/workspace/matrix-model.js +6 -2
  117. package/src/workspace/run-detail.js +35 -9
  118. 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
+ };
@@ -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
- { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
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.1' } },
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
- { alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
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 a workflow can only use aliases this table ships a CI
116
- // runner has no user config, so a locally-defined alias resolves to
117
- // nothing there. Pinned to the full model, not `inkling-small`: the bench
118
- // seat wants the flagship's judgment. `:batch` is deliberately not pinned
119
- // (deferred completion is wrong for an interactive council leg).
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
 
@@ -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 council
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
- const KINDS = Object.freeze(new Set(['degrade', 'heal']));
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 'heal')`);
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
- const lead = record.kind === 'heal' ? 'Recovered' : 'Notice';
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 };