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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +249 -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 +36 -15
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +18 -8
- 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.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/template/render.js +16 -7
- 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 +11 -3
- 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 +10 -2
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +33 -7
- 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
|
+
};
|
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
|
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
|