amicus 4.8.1 → 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 (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +36 -15
  10. package/electron/offer-session.js +51 -0
  11. package/electron/setup-ui.js +18 -8
  12. package/electron/workspace-ui/live-dead-seats.js +163 -91
  13. package/electron/workspace-ui/workspace-banners.js +30 -7
  14. package/electron/workspace-ui/workspace-matrix.js +23 -3
  15. package/electron/workspace-ui/workspace-seats.js +95 -79
  16. package/package.json +1 -1
  17. package/schemas/council-run.schema.json +2 -2
  18. package/schemas/council-tally.schema.json +17 -1
  19. package/schemas/council-verdict.schema.json +12 -4
  20. package/schemas/run.schema.json +6 -1
  21. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  22. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  23. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  24. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  25. package/skills/second-opinion/SKILL.md +151 -36
  26. package/src/cli-council-run-bench.js +98 -6
  27. package/src/cli-handlers-council-run.js +18 -6
  28. package/src/cli-handlers-council.js +57 -7
  29. package/src/cli-handlers-doctor.js +1 -1
  30. package/src/cli.js +3 -1
  31. package/src/council/anonymize.js +2 -1
  32. package/src/council/briefings-chair-task.js +161 -0
  33. package/src/council/briefings-chair.js +33 -8
  34. package/src/council/briefings-debate.js +79 -13
  35. package/src/council/briefings-stage2-task.js +236 -0
  36. package/src/council/briefings-stage2.js +103 -26
  37. package/src/council/briefings-task.js +167 -0
  38. package/src/council/briefings.js +41 -4
  39. package/src/council/chair-fallback.js +95 -0
  40. package/src/council/debate.js +38 -21
  41. package/src/council/findings.js +3 -2
  42. package/src/council/ledger.js +2 -2
  43. package/src/council/parse-stage2.js +63 -15
  44. package/src/council/report-cost.js +61 -0
  45. package/src/council/report-html.js +26 -4
  46. package/src/council/report-md.js +30 -2
  47. package/src/council/report.js +40 -37
  48. package/src/council/run-assemble.js +21 -6
  49. package/src/council/run-chair.js +44 -95
  50. package/src/council/run-debate-revote.js +81 -49
  51. package/src/council/run-debate.js +51 -34
  52. package/src/council/run-finish.js +5 -3
  53. package/src/council/run-retry-keys.js +4 -4
  54. package/src/council/run-retry-launch.js +4 -4
  55. package/src/council/run-retry-notes.js +72 -15
  56. package/src/council/run-stage1-launch.js +4 -4
  57. package/src/council/run-stage1-rows.js +9 -6
  58. package/src/council/run-stage2.js +81 -47
  59. package/src/council/run-stages.js +9 -21
  60. package/src/council/run-stats-entry.js +46 -1
  61. package/src/council/run.js +28 -13
  62. package/src/council/seats.js +2 -2
  63. package/src/council/stage1-bind.js +3 -2
  64. package/src/council/verdict-seat-loss.js +124 -0
  65. package/src/council/verdict.js +108 -99
  66. package/src/headless.js +256 -49
  67. package/src/mcp-council-bench.js +64 -3
  68. package/src/mcp-council-run.js +10 -3
  69. package/src/mcp-server.js +52 -12
  70. package/src/mcp-tools.js +41 -5
  71. package/src/observe/council-legs.js +2 -2
  72. package/src/opencode-client.js +19 -1
  73. package/src/pack/pack-forward.js +15 -12
  74. package/src/pack/pack-resolve.js +1 -1
  75. package/src/prompt-builder.js +17 -1
  76. package/src/sidecar/fanout-leg.js +26 -0
  77. package/src/sidecar/fanout.js +1 -1
  78. package/src/sidecar/list-council.js +178 -0
  79. package/src/sidecar/list-limit.js +3 -1
  80. package/src/sidecar/list-search.js +2 -1
  81. package/src/sidecar/models.js +8 -1
  82. package/src/sidecar/read.js +34 -10
  83. package/src/template/render.js +16 -7
  84. package/src/utils/alias-shadow-writer.js +220 -0
  85. package/src/utils/alias-shadow.js +294 -0
  86. package/src/utils/config.js +1 -1
  87. package/src/utils/curated-models.js +11 -3
  88. package/src/utils/degrade.js +12 -5
  89. package/src/utils/doctor-alias-check.js +2 -5
  90. package/src/utils/engine-log-parse.js +289 -0
  91. package/src/utils/engine-log-tail.js +114 -0
  92. package/src/utils/engine-log.js +250 -0
  93. package/src/utils/engine-skew-records.js +146 -0
  94. package/src/utils/engine-skew.js +300 -0
  95. package/src/utils/gateway-router.js +10 -2
  96. package/src/utils/model-catalog.js +1 -1
  97. package/src/utils/result-schema.js +10 -0
  98. package/src/utils/text-sanitize.js +81 -0
  99. package/src/utils/ttft.js +57 -0
  100. package/src/utils/untrusted-fence.js +111 -1
  101. package/src/workspace/fold-format.js +28 -7
  102. package/src/workspace/live-normalize.js +2 -1
  103. package/src/workspace/matrix-model.js +6 -2
  104. package/src/workspace/run-detail.js +33 -7
  105. package/src/workspace/seat-space.js +10 -6
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @module utils/alias-shadow-writer
3
+ * The alias-shadow notice's WRITE half: say it without ever sinking the run.
4
+ *
5
+ * EXTRACTED from src/utils/alias-shadow.js (PR #207 round 4). That file already
6
+ * separated the CHECK (read two alias tables, compare canonical forms) from the
7
+ * WRITE (get one advisory line onto a stream that may be a closed pipe), and
8
+ * rounds 2 and 3 put two independent hardenings on the write half alone —
9
+ * `safeWrite`'s synchronous guard and `armStream`'s asynchronous one. Round 4's
10
+ * B1 landed a third. The seam was already there; the file was at 286/300, and
11
+ * shaving the measurement prose that makes these guards auditable would have
12
+ * been the wrong economy, so the write half moved out whole.
13
+ *
14
+ * Nothing here was ever part of `alias-shadow.js`'s `module.exports` — these are
15
+ * internal helpers, not a public API — so there is no re-export shim and no
16
+ * import path anywhere in the tree that changes.
17
+ *
18
+ * Named mutants live with their red sets in tests/alias-shadow.test.js:
19
+ * "WRITERFATAL" (drop `safeWrite`'s try/catch), "STREAMFATAL" (drop
20
+ * `armStream`'s attach-once 'error' handler) and "STREAMDEAF" (round 5 — put
21
+ * the PURE no-op handler back, so the arming goes deaf to every class again
22
+ * instead of only the benign one).
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ /**
28
+ * Write a notice without ever letting the writer sink the run (round 2, B1).
29
+ *
30
+ * The 'never throws' contract used to cover only the CHECK: the guard wrapped
31
+ * `findAliasShadows`, so a writer that threw — a caller-supplied collector that
32
+ * rejects, a stream whose write throws — escaped and killed the launch this
33
+ * diagnosis exists to protect. Worse, on the failure branch it escaped a second
34
+ * time, because that branch announced itself through the SAME broken writer. A
35
+ * notice must never be fatal to what it is describing, so the write is swallowed
36
+ * here and nowhere else.
37
+ *
38
+ * ⚠️ This covers the SYNCHRONOUS half only. A piped stderr fails on a later turn
39
+ * and never throws from `write()` at all — see `armStream` below, which is the
40
+ * other half of the same contract.
41
+ * @param {(line: string) => void} out
42
+ * @param {string} line
43
+ */
44
+ function safeWrite(out, line) {
45
+ try { out(line); } catch { /* a diagnosis must never sink the run it diagnoses */ }
46
+ }
47
+
48
+ /**
49
+ * The armed marker, keyed ON THE STREAM (PR #207 round 4, B1).
50
+ *
51
+ * It used to be a module-scoped `WeakSet`, which made "attach once" true only
52
+ * once per MODULE INSTANCE. `jest.resetModules()` builds a fresh registry, the
53
+ * fresh copy of this module starts with an empty set, and the SAME
54
+ * `process.stderr` collects a second listener — measured at SEVEN across
55
+ * tests/alias-shadow.test.js alone, against Node's 10-listener
56
+ * MaxListenersExceededWarning. That warning is emitted ASYNCHRONOUSLY onto
57
+ * `process.stderr.write`, which is the method several of this feature's absence
58
+ * controls replace and exact-match on, so the accumulation was a
59
+ * nondeterministic flake waiting for two more fixtures. A marker that lives on
60
+ * the stream is immune: it is the same object either way.
61
+ *
62
+ * `Symbol.for`, not `Symbol()`: the well-known-symbol registry is per REALM, so
63
+ * every instance of this module — reset registry, second resolved path, npx
64
+ * copy loaded beside a global one — computes the SAME key and therefore agrees
65
+ * about what is already armed. A module-local `Symbol()` would be a fresh key
66
+ * per instance and would re-arm exactly like the WeakSet did.
67
+ *
68
+ * It also retains nothing: a swapped-out stream (tests do this) is dropped with
69
+ * its own marker attached, where the WeakSet had to be weak on purpose.
70
+ */
71
+ const ARMED = Symbol.for('amicus.alias-shadow.armed');
72
+
73
+ /**
74
+ * The self-report marker (PR #207 round 5, A3+B2+D1+C2). Same `Symbol.for`
75
+ * registry discipline, and the same reason, as `ARMED` above: one report per
76
+ * STREAM, agreed on by every instance of this module.
77
+ */
78
+ const REPORTED = Symbol.for('amicus.alias-shadow.reported');
79
+
80
+ /**
81
+ * Stream failures that mean "the reader went away", and nothing more.
82
+ *
83
+ * This is the class the arming exists for: `amicus … | head`, a closed MCP
84
+ * client, a terminal that went away mid-write. There is nobody left to tell, so
85
+ * telling is pointless — these are swallowed in silence. Everything else is a
86
+ * real fault and gets said out loud (see `armStream`).
87
+ *
88
+ * `EOF` is here beside `EPIPE` because that is what a severed pipe reports on
89
+ * Windows, where this is developed; `ERR_STREAM_DESTROYED` and
90
+ * `ERR_STREAM_WRITE_AFTER_END` are Node's own spellings of the same "the sink is
91
+ * gone" fact for a destroyed/ended stream.
92
+ */
93
+ const BENIGN_STREAM_ERRORS = new Set([
94
+ 'EPIPE', 'EOF', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END',
95
+ ]);
96
+
97
+ /**
98
+ * Is this the reader-went-away class? A code-less error is NOT assumed benign —
99
+ * nothing says it is, and the honest default for an unrecognised fault is to
100
+ * report it.
101
+ * @param {*} err
102
+ */
103
+ function isBenignStreamError(err) {
104
+ return !!err && BENIGN_STREAM_ERRORS.has(err.code);
105
+ }
106
+
107
+ /**
108
+ * Make a stream's write failures non-fatal, once — WITHOUT going deaf (round 3
109
+ * A1; round 5 A3+B2+D1, and the contested C2, same mechanism).
110
+ *
111
+ * MEASURED, node v24.18.0 on Windows, against a REAL closed pipe (parent spawns
112
+ * a child with `stdio: ['ignore','ignore','pipe']` and destroys the read end;
113
+ * the child then writes to `process.stderr`):
114
+ *
115
+ * · `write(line)` returns FALSE and throws NOTHING. `safeWrite`'s try/catch
116
+ * sees nothing at all. The EPIPE arrives on a LATER turn, as an 'error'
117
+ * event; with no listener, EventEmitter throws it, and that throw is an
118
+ * uncaughtException that ends the process (measured: exit code 7).
119
+ * · Passing a write CALLBACK does NOT fix it. The callback received the EPIPE
120
+ * AND the 'error' event still fired unhandled — same exit 7. This is why
121
+ * there is no callback here: it would observe the failure without disarming
122
+ * it, and read like a guard while being none.
123
+ * · Attaching for the duration of the write and detaching after is not merely
124
+ * racy, it is always WRONG: delivery is always on a later turn, so the
125
+ * detach always wins (measured: exit 7 again, with the handler's own log
126
+ * line showing it was removed before the error landed).
127
+ * · A persistent listener absorbs it, and a SECOND write raises a SECOND
128
+ * 'error' — so this must be `on`, never `once`.
129
+ *
130
+ * Hence: attach once, per stream object, and leave it.
131
+ *
132
+ * ⚠️ THE SCOPE IS THE WHOLE PROCESS, PERMANENTLY, and the round-3 text undersold
133
+ * it. From the first notice onward this handler receives EVERY 'error' raised on
134
+ * `process.stderr` by ANY producer in the process — not only the EPIPE from this
135
+ * feature's own write, and not only for the duration of the notice. It is
136
+ * sharpest in the MCP server, which imports this in-tree and then outlives every
137
+ * individual run. A pure no-op there would silently discard unrelated stderr
138
+ * faults for the life of the server, which is precisely the correct-but-SILENT
139
+ * degrade the product principle forbids. So the handler DISCRIMINATES: the
140
+ * benign reader-went-away class above is swallowed, and anything else is
141
+ * reported through the house logger.
142
+ *
143
+ * ⚠️ WHY THE REPORT IS BOUNDED TO ONE. MEASURED: `utils/logger.js` writes with
144
+ * `console.error`, i.e. onto `process.stderr` — the same stream that just
145
+ * failed (its own header: "Outputs JSON-formatted logs to stderr"). An unbounded
146
+ * report could therefore provoke the next 'error', handle it, report again, and
147
+ * spin the event loop forever — round 3's B1 defect ("the announcement becomes
148
+ * the next escape") in its asynchronous form. One report per stream, wrapped in
149
+ * its own try/catch, is what makes speaking up safe. The report is best-effort
150
+ * BY CONSTRUCTION and is documented as such rather than promised.
151
+ *
152
+ * Checked rather than assumed: nothing in src/, bin/ or scripts/ attaches to or
153
+ * depends on `process.stderr`'s 'error' event (the `.on('error')` hits in the
154
+ * tree are all on CHILD process streams), and adding a listener removes nobody
155
+ * else's — any handler another module attaches still runs alongside this one.
156
+ * `electron/main.js` installs a listener on the same stream for the same
157
+ * round-3 reason, but its handler is silent for EVERY code; this one
158
+ * deliberately departs from that precedent for the non-benign class, which is
159
+ * exactly why the bound above had to exist first.
160
+ *
161
+ * ⚠️ Scoped to the module's OWN default writer. An INJECTED writer (every test
162
+ * collector, and the MCP notices array) never reaches here, so nothing is armed
163
+ * on its behalf.
164
+ * @param {NodeJS.WritableStream} stream
165
+ * @param {(msg: string, ctx: object) => void} [log] the diagnostic seam —
166
+ * injected by tests; defaults to the house logger's `error`.
167
+ */
168
+ function armStream(stream, log) {
169
+ if (!stream || typeof stream.on !== 'function' || stream[ARMED]) { return; }
170
+ try {
171
+ // `configurable` so a test that borrows a real stream can still take it
172
+ // back; symbol-keyed, so it is invisible to `Object.keys`, `JSON.stringify`
173
+ // and anything else that walks the stream object we are borrowing.
174
+ Object.defineProperty(stream, ARMED, { value: true, configurable: true });
175
+ } catch {
176
+ // A stream nobody can MARK cannot be armed exactly once, and arming it
177
+ // without a mark would trade round 3's unhandled-'error' hazard for an
178
+ // unbounded-listener one on that same stream. Node never freezes
179
+ // `process.stderr`, so this is a guard, not a path.
180
+ return;
181
+ }
182
+ stream.on('error', (err) => {
183
+ // The reader went away. Nobody to tell; a diagnosis must never sink the run
184
+ // it diagnoses, and here it must not natter at a dead pipe either.
185
+ if (isBenignStreamError(err)) { return; }
186
+ if (stream[REPORTED]) { return; }
187
+ try {
188
+ Object.defineProperty(stream, REPORTED, { value: true, configurable: true });
189
+ } catch {
190
+ // Unreachable in practice: `ARMED` was defined on this same object moments
191
+ // ago, so the object takes symbol properties. Bounded-over-loud if it ever
192
+ // is reached — an unbounded report is the one failure mode worse than a
193
+ // missed one.
194
+ return;
195
+ }
196
+ try {
197
+ const report = log || require('./logger').logger.error;
198
+ // The message rides in the CONTEXT, not the format string: the logger
199
+ // JSON.stringifies the entry, and JSON.stringify escapes every C0 byte
200
+ // (MEASURED: ESC serializes as the six characters \u001b), so its text
201
+ // cannot repaint a terminal from here the way `formatAliasShadow`'s
202
+ // config fragments could.
203
+ report('alias-shadow notice: unexpected error on the notice stream', {
204
+ code: (err && err.code) || null,
205
+ error: err && err.message ? String(err.message) : String(err),
206
+ });
207
+ } catch { /* a diagnosis must never sink the run it diagnoses */ }
208
+ });
209
+ }
210
+
211
+ /** The default writer: stderr, armed against its own asynchronous failure. */
212
+ function writeNoticeToStderr(line) {
213
+ const stream = process.stderr;
214
+ armStream(stream);
215
+ stream.write(line);
216
+ }
217
+
218
+ // `ARMED` is deliberately NOT exported: it is this module's private marker, and
219
+ // a second holder of the key is a second thing that could clear it.
220
+ module.exports = { safeWrite, armStream, writeNoticeToStderr };
@@ -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,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.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
123
  // names it, and this table is the FLOOR a runner falls back to when no
@@ -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
  }
@@ -34,11 +34,8 @@
34
34
 
35
35
  const HINTS = require('./remediation-hints');
36
36
 
37
- // Mirrors cli-handlers-doctor.js's own MAX_CATALOG_AGE_MS (which itself
38
- // mirrors model-catalog.js's DEFAULT_MAX_AGE_MS) -- duplicated rather than
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