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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.0",
3
+ "version": "4.9.2",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,130 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.9.2] - 2026-08-27
7
+
8
+ *The instrument existed; nothing could read it.*
9
+
10
+ Issue #202 deferred its retry-policy decision to evidence: "v4.9 W13 records per-leg
11
+ time-to-first-token in `runStats`, so the next rev can derive this from observation instead of
12
+ argument." That probe had never reported a value into any artifact CI uploads — `tally.js`'s
13
+ hand-maintained allowlist stripped it one hop before `tally.json`. Reading it changed the
14
+ diagnosis: first tokens on the CI egress are a continuous heavy tail (8.0 s to 384.2 s, no gap),
15
+ not an upstream that accepts and never serves. This release fixes the instrument, then the
16
+ kill switches that were set inside that tail.
17
+
18
+ ### Fixed
19
+
20
+ - **`ttftMs` survives the runStats re-projection (#202).** `tally.js :: tally` re-projects every
21
+ row through a hand-maintained allowlist that never named the field, and `verdict.js` copies that
22
+ array verbatim — so the W13 probe emitted correctly into `tally-input.json` and was destroyed
23
+ before `tally.json` and `verdict.json`, the only artifacts CI uploads. MEASURED, run
24
+ 33030485388: 11 of 12 rows carried it going in, 0 of 12 coming out. `tally.js` becomes the fifth
25
+ emit gate and the fourth importer of the shared `isMeasuredTtft` predicate. A drift pin now fails
26
+ for ANY future `buildRunStatsEntry` key the allowlist is not taught to carry.
27
+ - **A zero-output leg now names its cause (#202).** `getSessionStatus` was called only inside
28
+ `if (mirror.output.length > 0)` — a gate a leg that produced nothing never satisfies — so the one
29
+ leg needing diagnosis was the only one that never asked. A bounded, non-throwing read now runs at
30
+ the two backstop firing sites (a living leg makes no extra call) and appends `busy` /`idle`/
31
+ `retry` with the upstream message. `busy` means provider-side, `idle` means engine-side, `retry`
32
+ names the cause; none is suppressed. Untrusted provider text is sanitized.
33
+ - **Dead Stage-2 judge legs are announced (#202).** A dead judge leg still binds to its seat, so it
34
+ was neither `orphan` nor `unbound` and Stage 2 had no third case: it fell through into
35
+ `judgeResults` unremarked. Run 32956900910 shipped a four-column adjudication matrix that two of
36
+ its four judges never voted in, with no degrade recorded. New `stage2-judge` channel.
37
+ - **B53 no longer kills healthy, billing legs.** `TOOL_CALL_STALL_MS` was 180 s, condemned by this
38
+ repo's own measurement — a real 190.6 s `task` call recorded in `headless.js`, taken on a
39
+ developer machine. That measurement had corrected its neighbour (the settle deferral) and left
40
+ its own subject alone. Now 300 s, with CI overriding to 480 s.
41
+ - **The Stage-1 retry backstop no longer ties with the leg timeout.** `min(2 * backstop, legCap)`
42
+ made the deadlines equal whenever `2 * backstop >= legCap`; the backstop won only by the poll
43
+ loop's ordering. It now clamps strictly below, so a retry death keeps its named diagnosis
44
+ instead of degrading to a generic `timeout`.
45
+
46
+ ### Added
47
+
48
+ - **`verdict.json` publishes `seatsReviewed {reviewed, of}`.** `deriveSeatLoss` returns null when
49
+ no `--critic` was requested, and CI requests none, so seat loss was structurally absent from
50
+ every CI verdict while a two-seat bench published a four-model street-cred table. Derived from
51
+ `runStats`, counting the bench roles `buildSeats` mints (`seat`, `critic`, `lens:<slug>`).
52
+ Surfaced in the check-run title and the sticky comment footer.
53
+
54
+ ### Changed
55
+
56
+ - **A dead Stage-2 judge now degrades the run, so it exits 2.** Previously a half-adjudicated
57
+ verdict could exit 0. This changes CI signal: runs that passed before will now report degraded
58
+ when a judge dies.
59
+ - **CI council caps.** Per-leg `--timeout` 10 -> 16 min and `timeout-minutes` 45 -> 75, to give the
60
+ tool-stall detector real reach (120 s -> 480 s of a leg). The job cap covers a worst case of
61
+ four leg caps; that figure is a floor, since Stage-2 repairs are serial.
62
+
63
+ ## [4.9.1] - 2026-08-27
64
+
65
+ *A silent provider failure, and the unservable model ids it produced.*
66
+
67
+ A session on `deepseek` failed with `you passed deepseek-v4-flash-0731`. Tracing that one error
68
+ found a chain: a provider fetch failed silently, the empty namespace it left behind was
69
+ indistinguishable from "never fetched", and that ambiguity licensed the wizard to synthesise and
70
+ persist a direct model id nothing serves.
71
+
72
+ ### Fixed
73
+
74
+ - **Per-provider catalog fetch failures are reported instead of collapsing to `[]` (#209).**
75
+ `fetchAllModels()` returned a flat row array, so a provider whose fetch was REJECTED could not be
76
+ told apart from one that legitimately serves no models — all four failure modes (non-200, timeout,
77
+ network error, parse error) resolved to a bare `[]`, and the existing reporting fired only when
78
+ *every* provider returned nothing. A 401ing key silently zeroed that vendor's namespace and every
79
+ picker then offered gateway-only routes with no indication why. `fetchAllModelsDetailed()` now
80
+ carries `{rows, failures}`, the cache persists `providerFailures` alongside the rows they
81
+ describe, and `amicus models --check` prints
82
+ `PROVIDER FETCH FAILED: <provider> (HTTP 401)` (`--json` carries the array).
83
+ `fetchAllModels()` keeps its signature as a rows-only wrapper.
84
+ - **A rejected namespace no longer yields a fabricated direct model id (#208).**
85
+ `classifyModel` returns `'unknown'` for an empty namespace because it could not distinguish
86
+ "never fetched" from "fetch rejected", and that licensed `directFormIfSafe` to strip the
87
+ `openrouter/` prefix and produce an id the direct API does not serve. Optimism is now suppressed
88
+ when *that vendor's* namespace fetch was rejected, and preserved when it was simply never
89
+ attempted. Threaded through the picker, the shortlist, both Electron wizard entry points and the
90
+ CLI setup path — the picker rebuilds its own `catalogInfo`, so without threading the guard was
91
+ live in unit tests and dead in production.
92
+ - **The persistence path requires catalog evidence (#214).**
93
+ `toStorableRoute` — whose result is written straight into `config.aliases` and used to seed fresh
94
+ configs — decided direct-vs-gateway with no catalog evidence at all. It now routes through
95
+ `directFormIfSafe`, and `toLiveSeedAliases` stops discarding evidence it was already handed.
96
+ - **Alias drift is computed against the same evidence the writer uses.**
97
+ `findDriftedStoredAliases` resolved its "current" value without `providerFailures`, so a rejected
98
+ namespace produced false drift whose suggested repair would have written back the very id #208
99
+ removed.
100
+ - **Guards no longer depend on an optional argument.** `directFormIfSafe`/`directFormIfProven`
101
+ keyed both the `DIVERGENT_VENDORS` and namespace-rejection checks on a caller-supplied `vendor`
102
+ that the API marks optional, while the catalog check derived its own — so a caller using the
103
+ documented shape silently lost two of three guards. The vendor is now derived from the id when
104
+ omitted.
105
+ - **The setup wizard keeps direct-first routing when the catalog is unavailable.** With no catalog,
106
+ quick picks fell back to raw `openrouter/…` routes, which Amicus treats as an explicit
107
+ force-OpenRouter literal — an offline setup would have pinned the user to the gateway
108
+ permanently.
109
+ - **`pre-commit` no longer fails on a clone that has never stashed.** The hook gated its
110
+ lint-staged workaround on being inside a worktree; the real trigger is a missing `refs/stash`,
111
+ which is true of any fresh clone whose first commit precedes its first `git stash`.
112
+
113
+ ### Changed
114
+
115
+ - **`toCanonicalDefault` is renamed `stripGatewayPrefix`** (`src/utils/curated-models.js`). The old
116
+ name read as the correct answer and three separate callers took it at its word and persisted ids
117
+ the direct API may not serve. It cannot be made evidence-taking — it *produces* the candidate
118
+ `classifyModel` checks — so the name now says what it is. Callers deriving an id that will be
119
+ called or stored must use `directFormIfSafe`/`directFormIfProven`. Internal utility, not a
120
+ documented API surface, but importers reaching into `src/utils/` will need the new name.
121
+ - **Route canonicalisation for the setup wizard is decided in the main process.** The renderer
122
+ carried a hand-copy of `toCanonicalDefault` that had neither of the real primitive's guards; it is
123
+ deleted, and the safe form now ships to the page as data. Routing policy no longer reaches the
124
+ renderer at all.
125
+ - **`gatewayOf(id)`** replaces three verbatim copies of the gateway-classification one-liner.
126
+ - An ESLint rule now bans hand-rolled `openrouter/` prefix stripping outside an audited allowlist.
127
+ - CI council bench: `qwen` moves from `qwen3.8-max` to `qwen3.8-27b` — same 1M context, ~4.8x
128
+ cheaper input. Bench-only; the shipped alias table is unchanged.
129
+
6
130
  ## [4.9.0] - 2026-08-26
7
131
 
8
132
  *The council does new work.*
package/README.md CHANGED
@@ -455,7 +455,7 @@ $ amicus status demo123 --json
455
455
  "taskId": "demo123",
456
456
  "status": "complete",
457
457
  "elapsed": "5m 0s",
458
- "version": "4.9.0",
458
+ "version": "4.9.2",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/docs/usage.md CHANGED
@@ -538,7 +538,7 @@ $ amicus status demo123 --json
538
538
  "taskId": "demo123",
539
539
  "status": "complete",
540
540
  "elapsed": "5m 0s",
541
- "version": "4.9.0",
541
+ "version": "4.9.2",
542
542
  "model": "google/gemini-2.5-flash",
543
543
  "phase": "terminal"
544
544
  }
@@ -184,11 +184,13 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
184
184
  let cfg = loadConfig();
185
185
  if (!cfg) {
186
186
  const { toLiveSeedAliases } = require('../src/utils/quick-picks');
187
- let catalog = [];
187
+ // issue 214: getCatalogInfo, not getCatalog -- toLiveSeedAliases PERSISTS
188
+ // these routes, so it must see which namespaces were rejected.
189
+ let catalogInfo = { models: [] };
188
190
  try {
189
- catalog = await require('../src/utils/model-catalog').getCatalog();
191
+ catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
190
192
  } catch (_err) { /* offline: pinned seeds */ }
191
- cfg = { aliases: toLiveSeedAliases(catalog) };
193
+ cfg = { aliases: toLiveSeedAliases(catalogInfo) };
192
194
  }
193
195
  if (!cfg.aliases) { cfg.aliases = {}; }
194
196
  if (defaultModel) { cfg.default = defaultModel; }
package/electron/main.js CHANGED
@@ -327,12 +327,20 @@ function createAmicusWindow() {
327
327
  async function createSetupWindow() {
328
328
  // Lazy-load setup UI to avoid loading it for sidecar mode
329
329
  const { buildSetupHTML } = require('./setup-ui');
330
- const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
330
+ const { resolveQuickPicks, canonicalRoutesFor, toStorableRoute } = require('../src/utils/quick-picks');
331
331
  let quickPicks;
332
332
  const shortlists = {};
333
333
  try {
334
- const catalog = await require('../src/utils/model-catalog').getCatalog();
334
+ // issue 208: getCatalogInfo (not getCatalog) -- the shortlist needs the
335
+ // per-provider fetch outcomes, or directFormIfSafe's namespace-failure
336
+ // gate cannot fire and a rejected namespace still yields bare direct ids.
337
+ const catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
338
+ const catalog = catalogInfo.models;
335
339
  quickPicks = resolveQuickPicks(catalog);
340
+ // issue 214: decide each pick's safe storable form HERE, with the catalog in
341
+ // hand, and ship it as data. The page cannot require() the canonicalisation
342
+ // primitives, and its hand-copy of them dropped their guards.
343
+ for (const p of quickPicks) { p.canonicalRoutes = canonicalRoutesFor(p, catalogInfo); }
336
344
 
337
345
  // issue 138: one vendor shortlist per family card, resolved server-side from
338
346
  // the same catalog the quick picks came from (no extra IPC round-trip).
@@ -341,7 +349,8 @@ async function createSetupWindow() {
341
349
  try {
342
350
  shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
343
351
  catalog,
344
- recommendedId: toStorableRoute(p),
352
+ providerFailures: catalogInfo.providerFailures,
353
+ recommendedId: toStorableRoute(p, catalogInfo),
345
354
  });
346
355
  } catch (_e) { /* a shortlist failure must never block the wizard */ }
347
356
  }
@@ -520,21 +529,26 @@ function createSettingsChildWindow() {
520
529
  // createSetupWindow awaits. A missing or corrupt cache reads back as null
521
530
  // and degrades to the same pinned fallback buildSetupHTML already applies
522
531
  // when no quickPicks are given.
523
- const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
532
+ const { resolveQuickPicks, canonicalRoutesFor, toStorableRoute } = require('../src/utils/quick-picks');
524
533
  const { readCache } = require('../src/utils/model-catalog');
525
534
  let quickPicks;
526
535
  const shortlists = {};
527
536
  try {
528
537
  const cacheDoc = readCache();
529
538
  const catalog = cacheDoc ? cacheDoc.models : [];
539
+ // issue 208: the cache doc carries the fetch outcomes for THESE rows.
540
+ const providerFailures = (cacheDoc && cacheDoc.providerFailures) || [];
530
541
  quickPicks = resolveQuickPicks(catalog);
542
+ // issue 214: see the note at the other resolveQuickPicks site.
543
+ for (const p of quickPicks) { p.canonicalRoutes = canonicalRoutesFor(p, { models: catalog, providerFailures }); }
531
544
 
532
545
  const { buildModelShortlist } = require('../src/utils/model-shortlist');
533
546
  for (const p of quickPicks) {
534
547
  try {
535
548
  shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
536
549
  catalog,
537
- recommendedId: toStorableRoute(p),
550
+ providerFailures,
551
+ recommendedId: toStorableRoute(p, { models: catalog, providerFailures }),
538
552
  });
539
553
  } catch (_e) { /* a shortlist failure must never block the wizard */ }
540
554
  }
@@ -11,9 +11,8 @@ const { buildLocalSectionHTML } = require('./setup-ui-local');
11
11
  const { buildLocalScript } = require('./setup-ui-local-script');
12
12
  const { getDefaultAliases } = require('../src/utils/config');
13
13
  const { getBrandName } = require('./toolbar');
14
- const { resolveQuickPicks } = require('../src/utils/quick-picks');
14
+ const { resolveQuickPicks, canonicalRoutesFor } = require('../src/utils/quick-picks');
15
15
  const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
16
- const { listDirectProviders } = require('../src/utils/provider-registry');
17
16
 
18
17
  /**
19
18
  * @param {object} [options={}]
@@ -30,17 +29,26 @@ function buildSetupHTML(options = {}) {
30
29
  quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
31
30
  shortlists = {},
32
31
  } = options;
32
+ // Council A1 (PR 215): a pick reaching the page WITHOUT canonicalRoutes makes
33
+ // pickRouteFor fall back to the raw openrouter/... route, which this codebase
34
+ // treats as an EXPLICIT force-OpenRouter literal that never reconsiders
35
+ // direct-first -- so an offline setup (pinned fallback above, catalog
36
+ // unavailable) would pin the user to the gateway permanently. Backfill from an
37
+ // EMPTY catalog: directFormIfSafe is optimistic when nothing disproves the bare
38
+ // form (restoring direct-first) while still refusing for DIVERGENT_VENDORS,
39
+ // which the deleted toBareIfDirect did not.
40
+ const picks = quickPicks.map(p =>
41
+ (p && p.canonicalRoutes) ? p : { ...p, canonicalRoutes: canonicalRoutesFor(p, { models: [] }) });
33
42
  const brandName = getBrandName(client);
34
43
  const keysHtml = buildKeysStepHTML(PROVIDERS);
35
- const modelHtml = buildModelStepHTML(quickPicks, undefined, undefined, shortlists);
44
+ const modelHtml = buildModelStepHTML(picks, undefined, undefined, shortlists);
36
45
  const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
37
46
  const css = buildWizardCSS();
38
47
  const providersJson = JSON.stringify(PROVIDERS);
39
- const modelChoicesJson = JSON.stringify(quickPicks);
48
+ const modelChoicesJson = JSON.stringify(picks);
40
49
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
41
50
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
42
51
  const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
43
- const directProvidersJson = JSON.stringify(listDirectProviders());
44
52
  return `<!DOCTYPE html>
45
53
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
46
54
  <style>${css}</style></head><body>
@@ -62,11 +70,11 @@ function buildSetupHTML(options = {}) {
62
70
  </div>
63
71
  </div>
64
72
  <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
65
- ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson)}
73
+ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
66
74
  </body></html>`;
67
75
  }
68
76
 
69
- function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson) {
77
+ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
70
78
  const keysJs = buildKeysScript();
71
79
  const aliasJs = buildAliasScript();
72
80
  const councilJs = buildCouncilScript();
@@ -83,7 +91,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
83
91
  var providerNamesData = ${providerNamesJson};
84
92
  var defaultAliases = Object.assign(Object.create(null), ${defaultAliasesJson});
85
93
  var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
86
- var directProviders = ${directProvidersJson};
87
94
  var routingChoices = {};
88
95
  var explicitRouteChoices = {};
89
96
  // issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
@@ -288,20 +295,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
288
295
  } else { nextBtn.disabled = false; }
289
296
  }
290
297
 
291
- // #61: an auto-selected (non-explicit) openrouter/<vendor>/<model> route
292
- // whose vendor also has a direct integration must be stored bare
293
- // (<vendor>/<model>) so the gateway router can policy-route it direct-first;
294
- // a stored openrouter/... string is treated as an explicit force-OpenRouter
295
- // literal and never reconsiders direct-first. Mirrors
296
- // src/utils/curated-models.js's toCanonicalDefault exactly. Gateway-only
297
- // vendors (not in directProviders, e.g. qwen/grok/glm/...) pass through
298
- // unchanged since OpenRouter is their only route anyway.
299
- function toBareIfDirect(route) {
300
- if (typeof route !== 'string' || route.indexOf('openrouter/') !== 0) { return route; }
301
- var rest = route.slice('openrouter/'.length);
302
- var vendor = rest.split('/')[0];
303
- return directProviders.indexOf(vendor) !== -1 ? rest : route;
304
- }
305
298
 
306
299
  // Single source of the route choice for a quick-pick row: explicit pill
307
300
  // choice if its key still exists, else first provider with a key, else
@@ -311,12 +304,12 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
311
304
  // issue 138: an explicit per-model choice overrides the family flagship.
312
305
  var picked = modelChoiceIds[mc.alias];
313
306
  if (picked) {
314
- // Deliberately NEVER toBareIfDirect(picked) here (unlike the auto-pick
315
- // canonicalization below): picked/modelOpenrouterIds come straight
316
- // from the shortlist's own id/data-or, and for a DIVERGENT_VENDOR
317
- // (e.g. anthropic) that id can already BE its only-callable
318
- // openrouter/<vendor>/... form -- stripping the prefix would
319
- // fabricate a direct id nothing serves.
307
+ // A drilled-down pick is returned VERBATIM -- never canonicalised (unlike
308
+ // the auto-pick below): picked/modelOpenrouterIds come straight from the
309
+ // shortlist's own id/data-or, which the picker already built through
310
+ // directFormIfSafe. For a DIVERGENT_VENDOR (e.g. anthropic) that id can
311
+ // already BE its only-callable openrouter/<vendor>/... form, so touching
312
+ // the prefix here would fabricate a direct id nothing serves.
320
313
  if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
321
314
  return modelOpenrouterIds[mc.alias] || picked;
322
315
  }
@@ -332,9 +325,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
332
325
  if (!prov) { prov = provs[0]; }
333
326
  }
334
327
  var route = mc.routes[prov] || null;
335
- // Only canonicalize auto-picks; an explicit "via OpenRouter" pill click
336
- // is a deliberate choice and is returned unchanged.
337
- if (route && !explicitRouteChoices[mc.alias]) { route = toBareIfDirect(route); }
328
+ // issue 214: the SAFE storable form is decided server-side (quick-picks.js
329
+ // canonicalRoutesFor) and shipped with the pick. The page must not re-derive
330
+ // it: its old hand-copy of stripGatewayPrefix dropped both of that
331
+ // primitive's guards. An explicit "via OpenRouter" pill stays unchanged.
332
+ if (route && !explicitRouteChoices[mc.alias]) {
333
+ route = (mc.canonicalRoutes && mc.canonicalRoutes[prov]) || route;
334
+ }
338
335
  return route;
339
336
  }
340
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.0",
3
+ "version": "4.9.2",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -229,6 +229,16 @@
229
229
  "criticSeated"
230
230
  ]
231
231
  },
232
+ "seatsReviewed": {
233
+ "type": "object",
234
+ "description": "#202, optional. How much of the BENCH actually reviewed, derived from runStats: `of` counts every BENCH-role row — `seat`, `critic`, or `lens:<slug>`, exactly the roles seats.js :: buildSeats mints (one per bench seat POST-retry, so a healed seat is counted once and its first attempt is `role:'superseded'`), and `reviewed` counts those whose leg completed. Judges, chair and repair rows are not bench seats and are excluded. EMIT-WHEN-SET: a record with no bench rows carries no key at all, because `0 of 0` would read as a measurement of an empty bench rather than as the absence it is. WHY IT EXISTS: the sibling `seatLoss` above is present only when --critic was requested, and CI runs none — so seat loss was structurally absent from every CI verdict while a two-seat bench published a four-model street-cred table whose dead seats rendered `n/a`, indistinguishable from the legend's neutral (MEASURED, run 4424218c). No `additionalProperties: false` at the top level of this schema means an additive field was always accepted here; this documents the shape rather than changing what is accepted.",
235
+ "properties": {
236
+ "reviewed": { "type": "integer", "minimum": 0, "description": "Bench seats whose leg completed." },
237
+ "of": { "type": "integer", "minimum": 1, "description": "Bench seats benched, post-retry." }
238
+ },
239
+ "required": ["reviewed", "of"],
240
+ "additionalProperties": false
241
+ },
232
242
  "degrades": {
233
243
  "description": "v4.6 Plan 2: what this run lost — copied verbatim from the sink at verdict assembly. Additive; absent when the sink recorded nothing. v4.9 widened kind with 'info': an announcement that is neither a loss nor a recovery (e.g. a task run's ledger-skipped note) — carried here without degrading the run.",
234
244
  "type": "array",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @module council/run-retry-window
3
+ * The Stage-1 retry's no-output window: how long a RELAUNCHED leg may stay
4
+ * silent before the backstop kills it.
5
+ *
6
+ * ⚠️ EXTRACTED, not shaved (release Constraint 6, and the 300-line gate that put
7
+ * `verdict-seat-loss.js` in its own leaf): #219's correction took run-retry.js to
8
+ * 314/300. Its own module also makes the property directly testable.
9
+ *
10
+ * SL-2 Task 5 (#129): a retry re-runs the SAME model under the SAME conditions,
11
+ * so a latency failure is structurally unhealable — double the window rather
12
+ * than repeat it. ⚠️ #135 C0 took the base 240s -> 600s; deliberate, see
13
+ * CHANGELOG (council A1, PR #182).
14
+ *
15
+ * ⚠️ CLAMPED STRICTLY BELOW the leg timeout, not TO it (#219, council gpt
16
+ * major). `Math.min(2 * backstop, legTimeoutMs)` made the two deadlines EQUAL
17
+ * whenever `2 * backstop >= legTimeoutMs` — exactly CI today (2 x 480000 ===
18
+ * 960000 === `--timeout 16`). The backstop still won, but only by epsilon and
19
+ * only because the poll loop tests its deadline BEFORE sleeping, so the final
20
+ * poll lands just past the wall. That is an undocumented accident of loop order;
21
+ * if it ever lost, the leg would die a generic `timeout` and throw away the
22
+ * named NO_OUTPUT_BACKSTOP diagnosis this clamp exists to preserve.
23
+ *
24
+ * A PROPORTIONAL headroom, not a fixed subtraction: a constant large enough to
25
+ * beat a poll cycle (seconds) would drive a small leg cap to zero or negative,
26
+ * and `ms <= 0` is the documented DISABLE hatch — silently disabling the backstop
27
+ * is far worse than the race it fixes. 5% of any realistic leg cap clears the 2 s
28
+ * poll interval by a wide margin.
29
+ *
30
+ * `2 * 0 === 0` still disables, because `Math.min(0, anything positive) === 0`.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /**
36
+ * @param {number} baseBackstopMs the first attempt's resolved no-output window
37
+ * @param {number} legTimeoutMs the per-leg hard cap ((o.timeout || 15) * 60_000)
38
+ * @returns {number} the retry's window: doubled, clamped strictly below the cap
39
+ */
40
+ function retryBackstopMs(baseBackstopMs, legTimeoutMs) {
41
+ // ⚠️ NOT floored at the first attempt's window, and #219 round 2 (glm) asked
42
+ // for exactly that — correctly observing that when `legTimeoutMs <= 2 * base`
43
+ // the retry window comes out slightly SHORTER than the attempt it exists to
44
+ // give room to (480000/480000 -> 456000). The observation is right; the remedy
45
+ // is worse than what it fixes, MEASURED across all three regimes:
46
+ //
47
+ // regime first(effective) unfloored floored unfloored gives
48
+ // cap = 2x base 480000 912000 912000 NAMED backstop
49
+ // cap = base 480000 456000 480000 NAMED backstop
50
+ // cap < base (t=3) 180000 171000 180000 NAMED backstop
51
+ //
52
+ // Flooring pins the window ONTO the leg cap in both degenerate regimes, which
53
+ // is the tie the headroom exists to break — so the leg dies a generic
54
+ // `timeout` and the named diagnosis is lost. That diagnosis is this module's
55
+ // entire purpose. The unfloored cost is bounded at 5% of the window (24 s at
56
+ // CI scale, 9 s at `--timeout 3`), and it is paid only where the leg cap
57
+ // already dominates the backstop. Trading ≤5% of one retry's patience for a
58
+ // named cause on every retry death is the right side of that trade.
59
+ return Math.min(2 * baseBackstopMs, Math.floor(legTimeoutMs * 0.95));
60
+ }
61
+
62
+ module.exports = { retryBackstopMs };
@@ -17,6 +17,7 @@
17
17
  const { materializeReviews, isAbortExit } = require('./run-launch');
18
18
  const runState = require('./run-state');
19
19
  const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
20
+ const { retryBackstopMs } = require('./run-retry-window');
20
21
  const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
21
22
  = require('./run-retry-notes');
22
23
  // briefingFor + bindRetryWave live in ./run-retry-launch (v4.8 T-A2 split); the pad/bind core it wraps is stage1-bind.js :: bindPaddedWave (SI-27).
@@ -54,17 +55,13 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [],
54
55
  const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [], twins,
55
56
  stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
56
57
  stillDeadRetryLegs: [], seatOf: new Map(), orphanLegs: [], attemptedSeats: new Set() };
57
- // Task 5 (#129): SL-2 retries the SAME model under the SAME conditions, so a
58
- // latency failure is structurally unhealable. Double the window, clamped to
59
- // the leg timeout so the failure CLASS stays NO_OUTPUT_BACKSTOP rather than
60
- // silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
61
- // the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
62
- // ⚠️ #135 C0 took this 240s -> 600s; deliberate, see CHANGELOG (council A1, PR #182).
58
+ // The retry window: doubled and clamped strictly below the leg timeout.
59
+ // Reasoning (and #219's correction) lives in ./run-retry-window extracted
60
+ // for the 300-line gate. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
63
61
  const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
64
- const escalatedBackstopMs = Math.min(
65
- 2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs()),
66
- legTimeoutMs,
67
- );
62
+ const escalatedBackstopMs = retryBackstopMs(
63
+ Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs(),
64
+ legTimeoutMs);
68
65
 
69
66
  for (const unit of groupStage1Losses(o, deadWaves, deadLegs, seatOf, twins)) {
70
67
  // Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
@@ -23,6 +23,9 @@ const stage2 = require('./briefings-stage2');
23
23
  const { parseJudgeOutput } = require('./parse-stage2');
24
24
  const { sanitizeName, isAbortExit } = require('./run-launch');
25
25
  const runState = require('./run-state');
26
+ // #219 (council, glm minor): `leg.error` is UNTRUSTED provider text. The house
27
+ // sanitizer — one sanitizer, one dialect (utils/text-sanitize.js).
28
+ const { collapseExcerpt } = require('../utils/text-sanitize');
26
29
  const { buildRunStatsEntry } = require('./run-assemble');
27
30
  // v4.8 PR3 Task 4: seat binding. artifactName is NOT re-exported from
28
31
  // run-launch.js (its exports stop at sanitizeName/isAbortExit), so it comes
@@ -169,9 +172,16 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
169
172
  fs.writeFileSync(path.join(o.runDir, name), leg.summary, { mode: 0o600 });
170
173
  }
171
174
  let conformance = 'clean';
172
- let parsed = (leg.status === 'complete' && leg.summary)
173
- ? parseJudgeOutput(leg.summary, parseCtx)
174
- : { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] };
175
+ // #202: ONE predicate for "this judge never answered at all", shared by the
176
+ // DEAD_LEG classification below and by the degrade it now raises. Spelling it
177
+ // twice is how the two would drift into disagreeing about which judges died —
178
+ // and note it is NOT `leg.status !== 'complete'`: a leg that completes with an
179
+ // EMPTY summary produced nothing either, and the DEAD_LEG arm has always
180
+ // treated it that way.
181
+ const legDied = !(leg.status === 'complete' && leg.summary);
182
+ let parsed = legDied
183
+ ? { ok: false, errors: [{ code: 'DEAD_LEG', detail: leg.error || leg.status }] }
184
+ : parseJudgeOutput(leg.summary, parseCtx);
175
185
  let attempts = 0;
176
186
  // ⚠️ LC-12: the judging text the repair prompt must carry, tracked exactly like
177
187
  // Stage-1's `repairing` so `judging` and `parsed.errors` always describe the SAME
@@ -216,6 +226,40 @@ async function runStage2(ctx, { reviews, labels, globalFindings, extraLabeled =
216
226
  if (parsed.ok) { conformance = 'repaired'; }
217
227
  }
218
228
  if (!parsed.ok) {
229
+ // #202: THE MISSING THIRD CASE. A dead judge leg still comes back as a leg
230
+ // object, so bindPaddedWave binds it — it is neither `orphan` nor
231
+ // `unbound`, and stage 2 had no case for it. It fell through into
232
+ // judgeResults with `ok:false` and vanished: MEASURED on CI run
233
+ // 32956900910 (wave 9d8029c8-s2), where glm and qwen judges died at +300s
234
+ // with zero tokens and run.json recorded no degrade at all, while the
235
+ // verdict shipped a four-column adjudication matrix two of them never
236
+ // voted in. The one net that might have caught it, `thin-cross-review`,
237
+ // fires only at `usableJudges < 2`; that run had exactly 2 of 4.
238
+ //
239
+ // ⚠️ Emitted with the default kind ('degrade'), so run-degrade.js's sink
240
+ // sets `degraded.value` and the run exits 2. That is a deliberate
241
+ // behaviour change (owner's call): before it, a half-adjudicated verdict
242
+ // could exit 0, and W11 only exited 2 because of an unrelated
243
+ // cost-accounting degrade. An unparseable-but-ANSWERED judge is a
244
+ // different fact and is deliberately excluded — it already darkens the
245
+ // seat's row via `conformance: 'unstructured'`, and it is repairable.
246
+ if (legDied) {
247
+ ctx.degrade.note({
248
+ channel: 'stage2-judge',
249
+ what: `judge ${judge} did not adjudicate`,
250
+ // #219: `why` is PROSE — it renders into run.json, the report and the
251
+ // sticky PR comment — so the provider's text is collapsed to one
252
+ // bounded line. `data.reason` below stays VERBATIM on purpose: it is
253
+ // the machine surface, it is JSON (nothing to inject), and truncating
254
+ // it would cost exactly the fidelity a reader opens run.json for.
255
+ why: `its Stage-2 leg ended '${leg.status}'`
256
+ + (leg.error ? `: ${collapseExcerpt(leg.error, 200)}` : ''),
257
+ effect: `the cross-review was adjudicated by fewer than the ${judges.length} judges the `
258
+ + 'bench implies; the run continues and will exit degraded (2)',
259
+ data: { judge, seat: seat ? seat.id : null, waveId: `${o.runId}-s2`,
260
+ status: leg.status, reason: leg.error || null },
261
+ });
262
+ }
219
263
  judgeResults.push({ judge, seat, ok: false, order: null, orderSeats: null, adjudications: null,
220
264
  conformance: leg.status === 'complete' ? 'unstructured' : 'clean',
221
265
  // #83 (v4.6 Plan 2): the judge's ORIGINAL Stage-2 wave leg, mirroring
@@ -6,6 +6,11 @@ const { peersOf, unattributedPeerDrops } = require('./peer-split');
6
6
  // the seat-keying in it — release Constraint 6 is EXTRACT, never shave).
7
7
  // computeStreetCred is re-exported below, so no existing import path moved.
8
8
  const { computeStreetCred } = require('./street-cred');
9
+ // #202: the TTFT probe's LAST emit gate — a RE-PROJECTION, so omitting the field
10
+ // here destroyed one already produced rather than failing to produce it. Through
11
+ // v4.9.1 utils/ttft.js's docblock enumerated only the four PRODUCER gates and
12
+ // stopped one short of this one; it now names all five.
13
+ const { isMeasuredTtft } = require('../utils/ttft');
9
14
 
10
15
  /**
11
16
  * Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
@@ -181,6 +186,13 @@ function tally(input) {
181
186
  ...(r.seat ? { seat: r.seat } : {}),
182
187
  status: r.status || 'unknown',
183
188
  durationMs: typeof r.durationMs === 'number' ? r.durationMs : null,
189
+ // #202: emit-when-VALID, in buildRunStatsEntry's own slot (between
190
+ // durationMs and usage) so G7b's key-order invariant holds for a row that
191
+ // carries it. NOT `durationMs`'s null-coercion above: a null here would be
192
+ // read as a measurement, and absence must keep its one meaning — "no
193
+ // substantive tick was ever observed". The shared predicate is imported
194
+ // rather than hand-spelled; this file has no require-free pin.
195
+ ...(isMeasuredTtft(r.ttftMs) ? { ttftMs: r.ttftMs } : {}),
184
196
  usage: r.usage || null,
185
197
  })),
186
198
  tierCounts: countTiers(outFindings),