amicus 4.9.0 → 4.9.2

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @module council/verdict-seats-reviewed
3
+ * #202: the bench-seat census for verdict.json, as a spreadable fragment.
4
+ *
5
+ * ⚠️ EXTRACTED, not shaved — release Constraint 6, and the same 300-line gate
6
+ * that put `verdict-seat-loss.js` in its own leaf: adding this to verdict.js
7
+ * took that file to 313/300. It could not join verdict-seat-loss.js either —
8
+ * that module is pinned to export EXACTLY its two functions.
9
+ *
10
+ * This is the ONE place that decides what "a bench seat" means, so the
11
+ * emit-when-set rule and the role filter cannot drift apart. `of` is every
12
+ * `role:'seat'` row — one per bench seat POST-retry, so a healed seat counts
13
+ * once while its first attempt is `role:'superseded'`; judges, chair and
14
+ * repairs are not bench seats. `reviewed` is those whose leg completed: a
15
+ * `timeout` is not a review any more than an `error` is.
16
+ *
17
+ * A LEAF: it requires nothing, matching its seat-loss sibling.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ /**
23
+ * @param {Array<object>|undefined} runStats
24
+ * @returns {{seatsReviewed?: {reviewed: number, of: number}}}
25
+ */
26
+ /**
27
+ * Is this runStats row a BENCH seat — something that was asked to review?
28
+ *
29
+ * ⚠️ These are exactly the three roles `seats.js :: buildSeats` mints, and that
30
+ * is the point: it is the producer, so this mirrors it rather than guessing.
31
+ * `role === 'seat'` alone (#219) counted ZERO on a `--lenses` run, where every
32
+ * seat carries `lens:<slug>` — so emit-when-set silently omitted the census from
33
+ * the runs using the richest bench. A critic counts too: it is an adversarial
34
+ * seat, but it reviews.
35
+ *
36
+ * An ALLOWLIST, not a denylist of judge/chair/repair/superseded: a new
37
+ * non-bench role added later must not silently inflate the denominator.
38
+ */
39
+ function isBenchRole(role) {
40
+ return role === 'seat' || role === 'critic'
41
+ || (typeof role === 'string' && role.startsWith('lens:'));
42
+ }
43
+
44
+ function seatsReviewedOf(runStats) {
45
+ // ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
46
+ // externally-supplied records that never touched tally() in-process — the MCP
47
+ // `record` param of mcp-tools.js :: amicus_verdict is `z.record(z.any())`,
48
+ // fully permissive — and this file's own tests hand it `runStats: {}`. A
49
+ // truthy non-array sails past `||` and throws on `.filter`, turning a missing
50
+ // census into a crashed verdict build. The closed-literal comment further down
51
+ // makes the same argument about the same caller.
52
+ const seats = (Array.isArray(runStats) ? runStats : []).filter(r => r && isBenchRole(r.role));
53
+ if (seats.length === 0) { return {}; }
54
+ return { seatsReviewed: {
55
+ reviewed: seats.filter(r => r.status === 'complete').length,
56
+ of: seats.length,
57
+ } };
58
+ }
59
+
60
+ module.exports = { seatsReviewedOf };
@@ -13,6 +13,10 @@ const { summarizeSeatLoss, deriveSeatLoss } = require('./verdict-seat-loss');
13
13
  // opts.overallVerdict, null in every Stage-4 manual path).
14
14
  const VERDICT_SCHEMA_VERSION = 2;
15
15
 
16
+ // #202: the bench-seat census leaf (the 300-line gate — same reason
17
+ // verdict-seat-loss.js is its own module).
18
+ const { seatsReviewedOf } = require('./verdict-seats-reviewed');
19
+
16
20
  /**
17
21
  * Merge a tally record with Claude's Stage-4 decisions into the verdict record.
18
22
  * @param {object} record tally() output
@@ -123,6 +127,25 @@ function buildVerdict(record, decisions = [], opts = {}) {
123
127
  // Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
124
128
  // was requested, so its absence never has to be interpreted.
125
129
  ...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
130
+ // #202: how much of the bench actually reviewed. DERIVED here rather than
131
+ // passed in, because every caller that could pass it already has the same
132
+ // `runStats` this reads — and a parameter is one more thing a rebuild path
133
+ // can forget (the `intent` key needed a SECOND carrier for exactly that).
134
+ //
135
+ // Its sibling `seatLoss` cannot serve: `deriveSeatLoss` returns null when no
136
+ // `--critic` was requested, and CI runs `CRITIC: ''` — so seat loss is
137
+ // STRUCTURALLY absent from every CI verdict. MEASURED on run 4424218c, a
138
+ // two-seat bench that published a four-model street-cred table with the dead
139
+ // seats rendered `n/a`, indistinguishable from the legend's "neutral".
140
+ //
141
+ // Counts the BENCH roles buildSeats mints — `seat`, `critic` and `lens:<slug>`
142
+ // (#219 r2: this said "`role:'seat'` ONLY" after the filter was widened, and
143
+ // two seats caught the stale sentence). One row per bench seat POST-retry, so a
144
+ // healed seat is counted once (its first attempt is `role:'superseded'`), and
145
+ // judges/chair/repairs are not bench seats. Emit-when-set — a record with no
146
+ // bench rows carries no key, because `0 of 0` would read as a measurement of
147
+ // an empty bench rather than as the absence it is.
148
+ ...seatsReviewedOf(record.runStats),
126
149
  // v4.6 Plan 2 (spec §4): the canonical what-was-lost surface. Additive and
127
150
  // OPTIONAL — present only when the run actually degraded, so a clean run's
128
151
  // verdict is byte-for-byte unchanged. schemaVersion stays 2 (the v4.5.2
package/src/headless.js CHANGED
@@ -25,6 +25,8 @@ const { envNumber } = require('./utils/env-num');
25
25
  const { engineErrorForSession } = require('./utils/engine-log');
26
26
  // v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
27
27
  const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
28
+ // #202: the session-status clause on a death report (see utils/session-status.js).
29
+ const { formatSessionStatusSuffix } = require('./utils/session-status');
28
30
  // v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
29
31
  // emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
30
32
  const { isMeasuredTtft } = require('./utils/ttft');
@@ -85,7 +87,18 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
85
87
  const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
86
88
  const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
87
89
  const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
88
- const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
90
+ // B53: wedged tool call w/ no progress.
91
+ // ⚠️ 180000 -> 300000 (#219, council glm minor). 180 s was condemned by this
92
+ // file's OWN measurement — the 190.6 s `task` call recorded below, taken on a
93
+ // developer machine, not on CI. #202 widened only the CI override and left every
94
+ // local and library consumer on the number the evidence had already disproved.
95
+ // 300000 matches the sibling constant that same measurement set (USAGE/settle
96
+ // deferral below), so one measurement now governs both windows it bears on.
97
+ const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 300000;
98
+ // #202: budget for the ONE session-status read on a death report. Short on
99
+ // purpose — this runs on a leg already known to be dying, so the report must not
100
+ // wait on the same engine that just failed to produce anything.
101
+ const STATUS_PROBE_MS = 5000;
89
102
  /**
90
103
  * v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
91
104
  * SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
@@ -120,9 +133,11 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
120
133
  * unbounded wait is not an option.
121
134
  *
122
135
  * WHY 5 MINUTES. The measured duration of the real subagent call that exposed
123
- * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — already longer
124
- * than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill a
125
- * healthy `task` leg 10 s short of its answer. 300 s clears the measured case
136
+ * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — which was longer
137
+ * than B53's THEN-180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
138
+ * a healthy `task` leg 10 s short of its answer. (#219 finally moved B53 itself
139
+ * to 300 s for this exact reason; for four releases this measurement corrected
140
+ * the neighbour and left its own subject alone.) 300 s clears the measured case
126
141
  * with margin and still lands far inside the 15-minute default `--timeout`.
127
142
  * Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
128
143
  *
@@ -228,7 +243,7 @@ function withTimeout(promise, ms, label) {
228
243
  * engineSkew?: {server: string, installed: string}|null}} args
229
244
  * @returns {string}
230
245
  */
231
- function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
246
+ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew, sessionStatus }) {
232
247
  const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
233
248
  + `${Math.round(ms / 1000)}s — `
234
249
  + (fromEnv
@@ -237,7 +252,11 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
237
252
  // Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
238
253
  const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
239
254
  // Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
240
- return `${quoted}${formatSkewSuffix(engineSkew)}`;
255
+ // #202 adds a THIRD clause on the same terms, and LAST so both clauses above
256
+ // stay byte-stable: no status (or an unreadable one) ⇒ '' — see
257
+ // utils/session-status.js. With none of the three the string is byte-for-byte
258
+ // what it was before any of them existed.
259
+ return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
241
260
  }
242
261
 
243
262
  /**
@@ -259,6 +278,45 @@ function engineErrorExcerptSafe(sessionId, engineLogOptions) {
259
278
  }
260
279
  }
261
280
 
281
+ /**
282
+ * #202: read the engine's session status FOR A DEATH REPORT — best-effort and
283
+ * bounded, with the same "never become the failure it reports on" discipline as
284
+ * `engineErrorExcerptSafe` above, and one more constraint that read does not
285
+ * have: this one does I/O against the very engine that just failed to produce
286
+ * anything, so it must also be unable to HANG. Both belts are load-bearing and
287
+ * both are pinned (S-W4 rejects, S-W5 hangs).
288
+ *
289
+ * A failed probe returns `null`, which `formatSessionStatusSuffix` renders as
290
+ * '' — so a leg whose status could not be read carries the byte-for-byte reason
291
+ * string it carried before #202, rather than a clause claiming nothing was
292
+ * happening. Absence keeps its one meaning.
293
+ *
294
+ * `readStatus` is a parameter rather than a module import because
295
+ * `getSessionStatus` is destructured inside runHeadless from the injectable
296
+ * client module — taking it here keeps this helper pure and directly testable.
297
+ * @returns {Promise<object|null>}
298
+ */
299
+ async function sessionStatusSafe(readStatus, client, sessionId, dirArgs, ms) {
300
+ // `!(ms > 0)` covers 0 (the documented disable), negatives and NaN — and it is
301
+ // why 0 is never handed to withTimeout, which would read it as UNBOUNDED.
302
+ if (typeof readStatus !== 'function' || !sessionId || !(ms > 0)) { return null; }
303
+ try {
304
+ return await withTimeout(
305
+ readStatus(client, sessionId, ...(dirArgs || [])), ms, 'getSessionStatus(death-report)');
306
+ } catch (err) {
307
+ // #219 (council, deepseek minor): returning null is right for the REPORT —
308
+ // absence keeps its one meaning — but it made a probe that timed out on a
309
+ // loaded engine indistinguishable from a leg whose engine reported nothing,
310
+ // i.e. a silent revert to pre-#202 behaviour. The engine log is where that
311
+ // belongs: the death report stays byte-identical, and the degradation
312
+ // becomes diagnosable instead of invisible.
313
+ logger.debug('session-status probe failed; the death report will carry no session clause', {
314
+ sessionId, error: err && err.message,
315
+ });
316
+ return null;
317
+ }
318
+ }
319
+
262
320
  /**
263
321
  * Wait for the OpenCode server to be ready using SDK health check
264
322
  */
@@ -616,6 +674,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
616
674
  // "time since the leg asked for output" (see the block comment above), so
617
675
  // the TTFT probe below measures from exactly the instant the backstop
618
676
  // starts counting — the two can never disagree about when the wait began.
677
+ // #202: resolved HERE, not with the poll-loop options below. The pre-send
678
+ // firing site calls noOutputBackstopReason() upstream of that block, so a
679
+ // later `const` put this in its TDZ and the death report became
680
+ // "Cannot access 'statusProbeMs' before initialization" — the exact class of
681
+ // silent-diagnosis loss #202 exists to remove. Pinned by S-W6.
682
+ // ⚠️ `=== undefined`, not `||` (#219 round 2, deepseek): 0 is a meaningful
683
+ // value in this codebase's convention (usageSettlePolls,
684
+ // AMICUS_NO_OUTPUT_BACKSTOP_MS) and must survive injection. It cannot be
685
+ // FORWARDED as 0 though — withTimeout reads `ms <= 0` as NO timeout, so an
686
+ // honest-looking 0 would make this probe unbounded on a leg already known to
687
+ // be dying. sessionStatusSafe therefore SKIPS on a non-positive window.
688
+ const statusProbeMs = options.statusProbeMs === undefined
689
+ ? STATUS_PROBE_MS : options.statusProbeMs;
619
690
  const outputClockStartedAt = Date.now();
620
691
  const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
621
692
  let backstopFired = false;
@@ -642,10 +713,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
642
713
  // was fixed mid-run, or one that belongs to another server this process
643
714
  // also talks to, cannot ride out on this death report. The read is a Map
644
715
  // lookup and does no I/O, so unlike the log read it needs no guard.
645
- const noOutputBackstopReason = () => formatNoOutputBackstopReason({
716
+ //
717
+ // #202 (piece 4): the closure is now ASYNC, because the third clause costs
718
+ // one bounded HTTP call. The engine's session status is asked for at
719
+ // :~1045 only when `mirror.output.length > 0` — a gate a zero-output leg
720
+ // never satisfies — so the leg that most needs diagnosing was the only one
721
+ // that never asked, and every silent death reported a window with no cause.
722
+ // Asked for HERE instead, at the two firing sites and nowhere else, so a
723
+ // living leg still makes no extra call. The read is best-effort and
724
+ // bounded: `sessionStatusSafe` can neither throw nor hang (S-W4/S-W5).
725
+ const noOutputBackstopReason = async () => formatNoOutputBackstopReason({
646
726
  ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
647
727
  engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
648
728
  engineSkew: currentEngineSkew(client),
729
+ sessionStatus: await sessionStatusSafe(getSessionStatus, client, sessionId, dirArgs, statusProbeMs),
649
730
  });
650
731
 
651
732
  // Send prompt asynchronously (returns immediately, we poll for results) —
@@ -696,7 +777,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
696
777
  // skipped entirely on this path — see the while-condition and the
697
778
  // backstop-abort block further down).
698
779
  if (backstopFired) {
699
- sessionError = noOutputBackstopReason();
780
+ sessionError = await noOutputBackstopReason();
700
781
  }
701
782
 
702
783
  // Hard provider failure detected at the client boundary (#37): a non-2xx /
@@ -1065,7 +1146,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1065
1146
  // loop; the post-loop block below mirrors the timeout path.
1066
1147
  if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
1067
1148
  backstopFired = true;
1068
- sessionError = noOutputBackstopReason();
1149
+ sessionError = await noOutputBackstopReason();
1069
1150
  logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
1070
1151
  break;
1071
1152
  }
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const { logger } = require('../utils/logger');
16
16
  const { classifyLegError, isRetryable } = require('../utils/error-classify');
17
17
  const { deriveChain } = require('./fallback-chains');
18
+ const { gatewayOf } = require('../utils/gateway-router');
18
19
 
19
20
  /**
20
21
  * Append ONE attributed ledger row for a single attempt (spec 6.2/7.1). At
@@ -38,7 +39,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
38
39
  // a substitution carries the substitute's resolved gateway on
39
40
  // `routeGateway` (threaded in by the loop, incl. v4.2 'local').
40
41
  const gateway = routeGateway || (leg && leg.gateway) ||
41
- (String(currentModel).startsWith('openrouter/') ? 'openrouter' : 'direct');
42
+ gatewayOf(currentModel);
42
43
  const row = {
43
44
  taskId: legId, waveId, model: currentModel, mode: 'leg', usage,
44
45
  op: 'leg', status: doc.status, gateway,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Presentation helpers for `amicus models` -- pure string formatting, no I/O.
3
+ *
4
+ * Split out of models.js when the per-provider failure line (issue 209) pushed
5
+ * that file past the 300-line ceiling. Formatting and command flow are
6
+ * separable concerns, so the ceiling picked the seam: everything here takes a
7
+ * plain object and returns a string.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ /** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
13
+ function perMtok(perToken) {
14
+ if (perToken === null || perToken === undefined) { return '—'; }
15
+ const n = Number(perToken);
16
+ if (Number.isNaN(n) || n < 0) { return '—'; }
17
+ return (n * 1e6).toFixed(2);
18
+ }
19
+
20
+ function fmtRow(m, aliasesById) {
21
+ const alias = aliasesById.get(m.id);
22
+ const aliasCol = alias ? `[${alias}] ` : '';
23
+ const ctx = m.contextLength ?? '—';
24
+ const pIn = perMtok(m.pricing && m.pricing.prompt);
25
+ const pOut = perMtok(m.pricing && m.pricing.completion);
26
+ return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
27
+ }
28
+
29
+ /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
30
+ function fmtGatewayFinding(f) {
31
+ if (f.kind === 'stale') {
32
+ return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
33
+ }
34
+ if (f.kind === 'divergent-missing') {
35
+ return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
36
+ }
37
+ return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
38
+ }
39
+
40
+ const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
41
+
42
+ /** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
43
+ * a probe result's `cost` is a bare number (models-probe.js doesn't carry the
44
+ * reported/estimated source tag), so this never claims a precision it can't back. */
45
+ function fmtProbeCost(cost) {
46
+ if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
47
+ return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
48
+ }
49
+
50
+ /** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
51
+ * class prefix padded to a fixed column, two-space indent — mirrors the STALE/
52
+ * DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
53
+ function fmtProbeLine(r) {
54
+ const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
55
+ if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
56
+ if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (no output within the probe window)`; }
57
+ return `${head} — ${r.detail}`;
58
+ }
59
+
60
+ /** One readable line per REJECTED provider fetch (issue 209): a namespace that is
61
+ * empty because its key was refused explains stale/absent aliases downstream.
62
+ * @param {{provider: string, reason: string, status?: number, detail?: string}} f
63
+ * @returns {string} */
64
+ function fmtProviderFailure(f) {
65
+ const why = f.reason === 'http-status' ? `HTTP ${f.status}` : (f.detail || f.reason);
66
+ return `PROVIDER FETCH FAILED: ${f.provider} (${why}) — its models are absent from the catalog`;
67
+ }
68
+
69
+ module.exports = {
70
+ perMtok, fmtRow, fmtGatewayFinding, PROBE_LABELS, fmtProbeCost, fmtProbeLine, fmtProviderFailure,
71
+ };
@@ -21,25 +21,11 @@ const { getFamilies } = require('../utils/curated-models');
21
21
  const { pickCurrent } = require('../utils/quick-picks');
22
22
  const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
23
23
  const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
24
+ const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
24
25
 
25
26
  const CHECK_EXIT_CAP = 100;
26
27
 
27
- /** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
28
- function perMtok(perToken) {
29
- if (perToken === null || perToken === undefined) { return '—'; }
30
- const n = Number(perToken);
31
- if (Number.isNaN(n) || n < 0) { return '—'; }
32
- return (n * 1e6).toFixed(2);
33
- }
34
28
 
35
- function fmtRow(m, aliasesById) {
36
- const alias = aliasesById.get(m.id);
37
- const aliasCol = alias ? `[${alias}] ` : '';
38
- const ctx = m.contextLength ?? '—';
39
- const pIn = perMtok(m.pricing && m.pricing.prompt);
40
- const pOut = perMtok(m.pricing && m.pricing.completion);
41
- return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
42
- }
43
29
 
44
30
  /** alias marks: id → comma-joined alias names (effective user aliases) */
45
31
  function aliasMarks() {
@@ -130,36 +116,9 @@ async function runRefresh(args) {
130
116
  return 0;
131
117
  }
132
118
 
133
- /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
134
- function fmtGatewayFinding(f) {
135
- if (f.kind === 'stale') {
136
- return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
137
- }
138
- if (f.kind === 'divergent-missing') {
139
- return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
140
- }
141
- return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
142
- }
143
119
 
144
- const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
145
120
 
146
- /** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
147
- * a probe result's `cost` is a bare number (models-probe.js doesn't carry the
148
- * reported/estimated source tag), so this never claims a precision it can't back. */
149
- function fmtProbeCost(cost) {
150
- if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
151
- return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
152
- }
153
121
 
154
- /** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
155
- * class prefix padded to a fixed column, two-space indent — mirrors the STALE/
156
- * DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
157
- function fmtProbeLine(r) {
158
- const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
159
- if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
160
- if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (no output within the probe window)`; }
161
- return `${head} — ${r.detail}`;
162
- }
163
122
 
164
123
  async function runCheck(args) {
165
124
  // v4.9 W13 Task B (BACKLOG C5). FIRST — ahead of the catalog-unavailable return
@@ -171,13 +130,17 @@ async function runCheck(args) {
171
130
  require('../utils/alias-shadow').auditAliasShadows();
172
131
  const catalogInfo = await getCatalogInfo();
173
132
  const catalog = catalogInfo.models;
133
+ const providerFailures = Array.isArray(catalogInfo.providerFailures) ? catalogInfo.providerFailures : [];
174
134
  if (!catalog || catalog.length === 0) {
175
135
  const probeSkipped = args.live ? 'catalog-unavailable' : null;
176
136
  if (args.json) {
177
137
  process.stdout.write(JSON.stringify(buildAuditDoc({
178
- stale: [], catalogAvailable: false, probeSkipped
138
+ stale: [], catalogAvailable: false, probeSkipped, providerFailures
179
139
  }), null, 2) + '\n');
180
140
  } else {
141
+ // Council C2 (PR 215): "catalog unavailable" is precisely when the user
142
+ // needs to know WHICH provider refused them.
143
+ for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
181
144
  process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
182
145
  if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
183
146
  }
@@ -186,7 +149,7 @@ async function runCheck(args) {
186
149
  const sources = collectAliasSources();
187
150
  const stale = findStaleAliases(sources, catalog)
188
151
  .map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
189
- const drifted = findDriftedStoredAliases(sources, catalog);
152
+ const drifted = findDriftedStoredAliases(sources, catalogInfo);
190
153
  // Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
191
154
  // (toGatewayRoutes()) — additive to the flat audit above. Informational by
192
155
  // default; --strict promotes it to a build-breaking exit code (CI gate).
@@ -221,10 +184,14 @@ async function runCheck(args) {
221
184
 
222
185
  if (args.json) {
223
186
  process.stdout.write(JSON.stringify(buildAuditDoc({
224
- stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
187
+ stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults, providerFailures
225
188
  }), null, 2) + '\n');
226
189
  return exitCode;
227
190
  }
191
+ // issue 209: report REJECTED provider fetches before the alias findings -- an
192
+ // empty namespace explains stale/absent aliases downstream, and staying
193
+ // silent about it is the original defect.
194
+ for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
228
195
  const driftLines = buildFallbackDriftReport(catalog);
229
196
  if (stale.length === 0 && drifted.length === 0) {
230
197
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
@@ -20,7 +20,8 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
20
20
  metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
21
21
  try {
22
22
  const { appendSpend } = require('../utils/spend-ledger');
23
- const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
23
+ const { gatewayOf } = require('../utils/gateway-router');
24
+ const gateway = metadata.gateway || gatewayOf(model);
24
25
  // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
25
26
  // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
26
27
  appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
@@ -475,9 +475,17 @@ async function runReadlineSetup() {
475
475
  await warnOnLowOpenRouterCredit();
476
476
  }
477
477
 
478
- const { getCatalog } = require('../utils/model-catalog');
478
+ const { getCatalogInfo } = require('../utils/model-catalog');
479
479
  let catalog = [];
480
- try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
480
+ // #208: carry the per-provider fetch outcomes alongside the rows, so the
481
+ // vendor shortlist below cannot synthesise a bare direct id for a
482
+ // namespace whose fetch was rejected rather than never attempted.
483
+ let providerFailures = [];
484
+ try {
485
+ const info = await getCatalogInfo();
486
+ catalog = info.models;
487
+ providerFailures = info.providerFailures || [];
488
+ } catch (_err) { /* offline: pinned */ }
481
489
 
482
490
  // Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
483
491
  // provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
@@ -537,7 +545,7 @@ async function runReadlineSetup() {
537
545
  }
538
546
 
539
547
  // Read-modify-write — never rebuild the alias table (no-clobber rule).
540
- const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
548
+ const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
541
549
  if (!cfg.aliases) { cfg.aliases = {}; }
542
550
  if (chosen.alias) {
543
551
  cfg.default = chosen.alias;
@@ -548,7 +556,7 @@ async function runReadlineSetup() {
548
556
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
549
557
  // skip the curated-flagship upgrade so it isn't discarded.
550
558
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
551
- cfg.aliases[chosen.alias] = toStorableRoute(pick);
559
+ cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
552
560
  } else if (cfg.aliases[chosen.alias] === undefined) {
553
561
  const fallback = getDefaultAliases()[chosen.alias];
554
562
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
@@ -599,6 +607,7 @@ async function runReadlineSetup() {
599
607
  const { buildModelShortlist } = require('../utils/model-shortlist');
600
608
  const shortlist = buildModelShortlist(pick.vendorPath, {
601
609
  catalog,
610
+ providerFailures,
602
611
  recommendedId: cfg.aliases[chosen.alias],
603
612
  });
604
613
  const specific = await promptForVendorModel(
@@ -219,6 +219,7 @@ async function startSidecar(options) {
219
219
  try {
220
220
  const { appendSpend } = require('../utils/spend-ledger');
221
221
  const { statusFromResult } = require('../utils/result-schema');
222
+ const { gatewayOf } = require('../utils/gateway-router');
222
223
  appendSpend({
223
224
  taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
224
225
  op: 'start', status: statusFromResult(result), project: effectiveProject,
@@ -227,7 +228,7 @@ async function startSidecar(options) {
227
228
  // inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
228
229
  // best-effort catch swallows → EVERY start-mode spend row silently dropped + start-json.test.js
229
230
  // goes red. Use an in-scope value (spec-complete for direct/openrouter):
230
- gateway: String(model).startsWith('openrouter/') ? 'openrouter' : 'direct',
231
+ gateway: gatewayOf(model),
231
232
  // (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
232
233
  // cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
233
234
  // v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
@@ -172,13 +172,20 @@ function suggestReplacements(staleModel, catalog, n = 3) {
172
172
  * @param {Array<{id:string}>} catalog
173
173
  * @returns {Array<{alias:string,stored:string,current:string}>}
174
174
  */
175
- function findDriftedStoredAliases(sources, catalog) {
176
- if (!catalog || catalog.length === 0) { return []; }
175
+ function findDriftedStoredAliases(sources, catalogOrInfo) {
176
+ // Council #216 A1/B2/C1: accepts catalogInfo (or a bare array, for existing
177
+ // callers). Passing models WITHOUT providerFailures made this compute the bare
178
+ // direct form for a REJECTED namespace while sidecar/setup.js persists the
179
+ // gateway form -- reporting drift that does not exist, and suggesting a repair
180
+ // that writes back the unservable direct id issue 208 removed.
181
+ const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
182
+ const catalog = info.models || [];
183
+ if (catalog.length === 0) { return []; }
177
184
  const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
178
185
  const current = new Map();
179
186
  for (const r of resolveQuickPicks(catalog)) {
180
187
  if (r.source !== 'live') { continue; }
181
- const stored = toStorableRoute(r);
188
+ const stored = toStorableRoute(r, info);
182
189
  if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
183
190
  }
184
191
  const byProvider = idsByProvider(catalog);
@@ -103,7 +103,7 @@ const { collapseExcerpt } = require('./text-sanitize');
103
103
  */
104
104
  function findAliasShadows(names) {
105
105
  const { loadConfig } = require('./config');
106
- const { toDefaultAliases, toCanonicalDefault } = require('./curated-models');
106
+ const { toDefaultAliases, stripGatewayPrefix } = require('./curated-models');
107
107
  const cfg = loadConfig();
108
108
  const userAliases = (cfg && cfg.aliases && typeof cfg.aliases === 'object') ? cfg.aliases : {};
109
109
  // Own keys only: a user config.json can carry a literal `__proto__`/`toString`
@@ -131,7 +131,7 @@ function findAliasShadows(names) {
131
131
  // ROUTING has its own audit (`models --check`'s per-gateway section); this
132
132
  // notice speaks only when the alias names a different MODEL. The rows still
133
133
  // report both sides RAW, so the user can grep their own config.
134
- if (toCanonicalDefault(local) === toCanonicalDefault(shipped)) { continue; }
134
+ if (stripGatewayPrefix(local) === stripGatewayPrefix(shipped)) { continue; }
135
135
  out.push({ alias, local, curated: shipped });
136
136
  }
137
137
  return out;
@@ -142,9 +142,11 @@ function getFamilies() {
142
142
  }
143
143
 
144
144
  /**
145
- * Direct-first canonicalization for a pinned `openrouter/<vendor>/<rest>`
146
- * route: when `<vendor>` has a direct integration (provider-registry
147
- * `isDirectProvider`), strip the `openrouter/` prefix so the resulting bare
145
+ * ⚠️ MECHANICAL primitive, NOT a routing decision (renamed from `toCanonicalDefault`,
146
+ * issue 214 model-canonicalization.js explains why that name was a trap). An id that
147
+ * will be CALLED or STORED must come from directFormIfSafe/directFormIfProven. Strips
148
+ * the `openrouter/` prefix off a pinned route when `<vendor>` has a direct integration
149
+ * (provider-registry `isDirectProvider`), so the resulting bare
148
150
  * `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
149
151
  * direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
150
152
  * integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
@@ -154,7 +156,7 @@ function getFamilies() {
154
156
  * @param {string} route
155
157
  * @returns {string}
156
158
  */
157
- function toCanonicalDefault(route) {
159
+ function stripGatewayPrefix(route) {
158
160
  if (typeof route === 'string' && route.startsWith('openrouter/')) {
159
161
  const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
160
162
  const slashIdx = rest.indexOf('/');
@@ -211,7 +213,7 @@ function vendorOf(orRoute) {
211
213
  function directFormFor(vendorPath, obj) {
212
214
  if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
213
215
  if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
214
- const bare = toCanonicalDefault(obj.openrouter); // safe only when ids are identical across gateways
216
+ const bare = stripGatewayPrefix(obj.openrouter); // safe only when ids are identical across gateways
215
217
  return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
216
218
  }
217
219
 
@@ -293,6 +295,6 @@ function toDefaultAliases() {
293
295
  }
294
296
 
295
297
  module.exports = {
296
- getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
298
+ getFamilies, toDefaultAliases, stripGatewayPrefix, listCuratedRoutes, toGatewayRoutes,
297
299
  directFormProvenance, DIVERGENT_VENDORS
298
300
  };
@@ -24,6 +24,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
24
24
  // a leg that DID match a slot but whose join key names no judge the wave launched.
25
25
  // Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
26
26
  'seat-unbound',
27
+ // #202: a Stage-2 JUDGE leg that came back dead — bound to its seat, so
28
+ // neither `seat-unbound` nor an orphan, and until now it had no channel at all
29
+ // and no case in run-stage2.js. Deliberately its own channel rather than
30
+ // `dead-leg`: that one is the Stage-1 BENCH roster's, feeds the retry pass and
31
+ // the seat-loss surface, and a judge death reused on it would be counted as a
32
+ // lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
33
+ // already gates the Stage-2 notes out of `seat-unbound` for the same reason).
34
+ 'stage2-judge',
27
35
  'internal',
28
36
  // doctor channels
29
37
  'doctor-check-failed', 'doctor-fix',