amicus 4.7.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +948 -1
  3. package/README.md +3 -2
  4. package/docs/CITATIONS.md +114 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +181 -34
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +6 -4
  13. package/docs/usage.md +15 -11
  14. package/electron/setup-ui-aliases.js +2 -2
  15. package/electron/workspace-ui/index.html +9 -0
  16. package/electron/workspace-ui/live-dead-seats.js +228 -0
  17. package/electron/workspace-ui/live-model.js +10 -236
  18. package/electron/workspace-ui/live-seats.js +126 -0
  19. package/electron/workspace-ui/workspace-app.js +6 -41
  20. package/electron/workspace-ui/workspace-banners.js +95 -0
  21. package/electron/workspace-ui/workspace-lazy.js +55 -12
  22. package/electron/workspace-ui/workspace-matrix.js +2 -2
  23. package/electron/workspace-ui/workspace-panels.js +42 -10
  24. package/electron/workspace-ui/workspace-render.js +2 -2
  25. package/electron/workspace-ui/workspace-seats.js +101 -17
  26. package/package.json +6 -3
  27. package/schemas/council-run-live.schema.json +1 -0
  28. package/schemas/council-run.schema.json +19 -0
  29. package/schemas/council-tally.schema.json +34 -2
  30. package/schemas/council-verdict.schema.json +15 -0
  31. package/scripts/postinstall.js +6 -3
  32. package/scripts/setup-hooks.js +49 -3
  33. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  34. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  35. package/skills/second-opinion/SKILL.md +25 -11
  36. package/skills/sidecar/SKILL.md +3 -1
  37. package/src/cli-handlers-doctor.js +7 -0
  38. package/src/cli-handlers-resume-continue.js +20 -0
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/peer-split.js +196 -0
  47. package/src/council/report-html.js +12 -5
  48. package/src/council/report-md.js +146 -0
  49. package/src/council/report.js +188 -112
  50. package/src/council/run-assemble.js +100 -79
  51. package/src/council/run-chair.js +17 -1
  52. package/src/council/run-debate-revote.js +268 -0
  53. package/src/council/run-debate.js +92 -102
  54. package/src/council/run-finish.js +70 -0
  55. package/src/council/run-launch.js +56 -18
  56. package/src/council/run-retry-group.js +266 -0
  57. package/src/council/run-retry-keys.js +74 -0
  58. package/src/council/run-retry-launch.js +55 -0
  59. package/src/council/run-retry-notes.js +65 -13
  60. package/src/council/run-retry.js +179 -162
  61. package/src/council/run-stage1-launch.js +27 -10
  62. package/src/council/run-stage1-rows.js +220 -0
  63. package/src/council/run-stage1-superseded.js +156 -0
  64. package/src/council/run-stage2.js +65 -5
  65. package/src/council/run-stages.js +72 -69
  66. package/src/council/run-state.js +1 -1
  67. package/src/council/run-stats-entry.js +71 -0
  68. package/src/council/run-verdict-files.js +52 -0
  69. package/src/council/run.js +42 -52
  70. package/src/council/seats.js +262 -0
  71. package/src/council/stage1-bind.js +142 -0
  72. package/src/council/street-cred.js +258 -0
  73. package/src/council/tally.js +100 -49
  74. package/src/council/verdict.js +49 -1
  75. package/src/headless.js +83 -6
  76. package/src/mcp-tools.js +71 -1
  77. package/src/observe/council-legs.js +7 -1
  78. package/src/observe/live-doc.js +3 -3
  79. package/src/sidecar/continue.js +8 -23
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-retry.js +15 -3
  82. package/src/sidecar/fanout-wave-io.js +26 -1
  83. package/src/sidecar/fanout.js +4 -10
  84. package/src/sidecar/leg-ids.js +19 -0
  85. package/src/sidecar/models-probe.js +7 -4
  86. package/src/sidecar/reopen-spend.js +32 -0
  87. package/src/sidecar/resume.js +1 -1
  88. package/src/sidecar/setup.js +13 -2
  89. package/src/utils/config.js +109 -11
  90. package/src/utils/curated-models.js +17 -2
  91. package/src/utils/degrade.js +5 -0
  92. package/src/utils/doctor-engine-check.js +32 -0
  93. package/src/utils/engine-install-scan.js +98 -15
  94. package/src/utils/engine-repair.js +96 -2
  95. package/src/utils/no-output-backstop.js +1 -1
  96. package/src/utils/remediation-hints.js +42 -0
  97. package/src/utils/session-index-prune.js +297 -0
  98. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  99. package/src/workspace/artifact-guard.js +8 -114
  100. package/src/workspace/artifact-names.js +222 -0
  101. package/src/workspace/fold-format.js +9 -6
  102. package/src/workspace/live-normalize.js +6 -2
  103. package/src/workspace/matrix-model.js +141 -19
  104. package/src/workspace/run-detail.js +27 -1
  105. package/src/workspace/seat-space.js +143 -0
@@ -73,7 +73,10 @@ function buildRetryPlan(origWaveId, project, { models } = {}) {
73
73
  } catch { /* legacy leg — fall back to briefing.md at launch time */ }
74
74
  eligible.push({ legId, model, systemPrompt, userMessage, hadSavedContext });
75
75
  }
76
- return { eligible };
76
+ // waveMeta has been read since :51 but was discarded; retryFailedWave needs
77
+ // the tag BEFORE it builds fanoutOpts, and its own origMeta re-read at :176
78
+ // runs after runFanoutImpl at :153 — too late to influence the launch.
79
+ return { eligible, tag: waveMeta.tag };
77
80
  }
78
81
 
79
82
  /**
@@ -121,7 +124,7 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
121
124
  // --json caller's stdout stays machine-parseable either way.
122
125
  const noopDoc = {
123
126
  ...buildWaveResult({ waveId: origWaveId, legs: [], status: 'complete' }),
124
- retryOf: origWaveId, effective: [], note: 'no failed legs',
127
+ retryOf: origWaveId, effective: [], note: 'no failed legs', tag: plan.tag,
125
128
  };
126
129
  process.stdout.write(JSON.stringify(noopDoc, null, 2) + '\n');
127
130
  } else {
@@ -148,7 +151,16 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
148
151
  // expects (parseModelsList/validateFanoutModels) — an array silently fails
149
152
  // every leg pre-flight (BAD_ARGS), matching run-launch.js:41's precedent.
150
153
  // Strip our own injection key so it is never forwarded.
151
- const fanoutOpts = { ...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId };
154
+ const fanoutOpts = {
155
+ ...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId,
156
+ // v4.7.1 Task 8: inherit the ORIGINAL wave's tag — sourced from disk
157
+ // (plan.tag, off waveMeta) never from opts.tag/args.tag. This is placed
158
+ // AFTER the `...opts` spread so it wins even if a caller's opts somehow
159
+ // carried a tag (the CLI itself already rejects --tag + --retry-failed
160
+ // upstream in cli-handlers-fanout.js). Absent-not-null idiom, matching
161
+ // fanout.js:152's `...(options.pack ? {...} : {})`.
162
+ ...(plan.tag ? { tag: plan.tag } : {}),
163
+ };
152
164
  delete fanoutOpts.runFanout;
153
165
  const { wave, exitCode } = await runFanoutImpl(fanoutOpts);
154
166
 
@@ -75,13 +75,38 @@ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedA
75
75
  /**
76
76
  * v4.3 §7.2 (moved here v4.7 PR3 Task 1): stamp council attribution onto every
77
77
  * leg — fanout-leg's appendSpend reads it; no-op for every non-council caller.
78
- * v4.7 F8 (Task 7) adds tag stamping in the same pass.
78
+ * v4.7 F8 (Task 7) adds tag stamping in the same pass. v4.8 R5 (T4.2) adds
79
+ * seat stamping in the same pass.
79
80
  */
80
81
  function stampLegAttribution(legs, options) {
81
82
  if (options.councilRunId || options.councilName) {
82
83
  legs.forEach(l => { l.councilRunId = options.councilRunId; l.councilName = options.councilName; });
83
84
  }
84
85
  if (options.tag) { legs.forEach(l => { l.tag = options.tag; }); }
86
+ // v4.8 R5: seat identity for the LIVE path. `options.seats` is the launching
87
+ // wave's roster, index-parallel with `options.models` by construction
88
+ // (run-stage1-launch.js builds `roster` and `models` from the same filter, and
89
+ // fanout-validate.js:66-87 pushes exactly one leg per model on BOTH its ok and
90
+ // its unroutable branch, so a leg that never routed still holds its slot).
91
+ //
92
+ // ⚠️ emit-when-DIFFERENT, against the seat's OWN alias — the shared predicate
93
+ // stated at run-stats-entry.js :: buildRunStatsEntry, which the three sites in
94
+ // run-assemble.js also spell. buildSeats mints `alias#N` ONLY when an alias
95
+ // repeats (seats.js:67), so `id !== alias` IS "the bench repeats this alias",
96
+ // and on a unique bench `id` IS the alias — a bare `if (s.id)` would stamp an
97
+ // alias-valued seat onto every leg of every run: the same wrong output shape
98
+ // run-assemble.js:165-169 records having already fixed once there (a
99
+ // wrong-comparator bug, `!== j.judge` rather than `!== alias`, with a
100
+ // narrower trigger — only a leg/seat alias drift, not every leg).
101
+ // Comparing against the seat's own alias and never against `model` also makes
102
+ // this immune to the two cases where `model` is NOT the alias (a leg reporting
103
+ // no modelInput; a padded --council member).
104
+ if (Array.isArray(options.seats)) {
105
+ legs.forEach((l, i) => {
106
+ const s = options.seats[i];
107
+ if (s && s.id !== s.alias) { l.seat = s.id; }
108
+ });
109
+ }
85
110
  }
86
111
 
87
112
  module.exports = { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution };
@@ -20,16 +20,10 @@ const { ERROR_CODES } = require('../utils/error-doc');
20
20
  // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
21
  // fanout tests import it from here.
22
22
  const { writeWaveMetadata, writeWaveDoc, finishWave, stampLegAttribution } = require('./fanout-wave-io');
23
-
24
- /**
25
- * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
26
- * @param {string} waveId
27
- * @param {number} count
28
- * @returns {string[]}
29
- */
30
- function deriveLegIds(waveId, count) {
31
- return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
32
- }
23
+ // Leg-id derivation lives in ./leg-ids (v4.8 PR0 size-gate split).
24
+ // deriveLegIds is re-exported below — mcp-server.js, fanout-retry.js and
25
+ // the fanout tests import it from here.
26
+ const { deriveLegIds } = require('./leg-ids');
33
27
 
34
28
  /**
35
29
  * Run a fan-out wave. Spec §4.3.
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+ // deriveLegIds — the <waveId>-<i+1> leg-id convention. Moved verbatim from
3
+ // fanout.js:24-32 (v4.8 PR0 size-gate split, zero behavior; fanout.js was
4
+ // 300/300). The shape is load-bearing: council stage-1 composes
5
+ // `${runId}-s1` waves onto it, and ~10 suites plus a replay fixture
6
+ // hard-code the composite. Pinned by tests/sidecar/fanout.test.js:84-93
7
+ // through fanout.js's re-export.
8
+
9
+ /**
10
+ * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
11
+ * @param {string} waveId
12
+ * @param {number} count
13
+ * @returns {string[]}
14
+ */
15
+ function deriveLegIds(waveId, count) {
16
+ return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
17
+ }
18
+
19
+ module.exports = { deriveLegIds };
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  /** Probe backstop override (spec D5) — a fixed constant, NOT env-configurable;
20
- * the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 120s leg default. */
20
+ * the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 300s leg default. */
21
21
  const PROBE_WINDOW_MS = 30000;
22
22
 
23
23
  /** Fixed tiny prompt — a probe leg only needs to prove the model answers at all. */
@@ -28,9 +28,12 @@ const PROBE_PROMPT = 'Reply with exactly: OK';
28
28
  * schema.js) per the plan's Global Constraints classification contract.
29
29
  * Precedence matters: 'complete' wins outright; otherwise a NO_OUTPUT_
30
30
  * BACKSTOP error (PR2's silent-leg detector, armed here at PROBE_WINDOW_MS
31
- * instead of its 120s default) is the one specific error shape that means
32
- * "the model accepted the request and never produced a token" rather than an
33
- * ordinary routing/auth/timeout failure.
31
+ * instead of its 300s default) is the one specific error shape that means
32
+ * NOTHING arrived in the window no output, no reasoning, no tool call —
33
+ * rather than an ordinary routing/auth/timeout failure. ⚠️ It does NOT prove
34
+ * the model "accepted the request": a stalled gateway or a dropped connection
35
+ * fires the same backstop. `accepted-but-silent` names the classification, not
36
+ * a fact about the endpoint (council B1 on PR #182).
34
37
  * @param {{status?:string, error?:string|null}} leg
35
38
  * @returns {'served'|'accepted-but-silent'|'error'}
36
39
  */
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module sidecar/reopen-spend
3
+ * Spend finalization for a REOPENED session (continue/resume). Split out of
4
+ * sidecar/continue.js to keep that file under the 300-line gate; resume.js was
5
+ * already reaching across for it, so the shared home is the honest one.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ /**
11
+ * Resolve a reopened session's usage, write it onto metadata, and append one
12
+ * attributed ledger row. Mirrors start.js's finalize (the only sites that
13
+ * dropped usage - BACKLOG.md :: "continue/resume never compute per-run usage" — ⚠️ cited `BACKLOG.md:280` until T-A8 re-opened it 2026-08-17: `:280` is a docs/usage.md line, the entry is `:289`). Best-effort ledger append; never throws.
14
+ * @returns {{usage: object|null}}
15
+ */
16
+ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
17
+ const { resolveUsage } = require('../utils/pricing');
18
+ const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
19
+ if (usage) {
20
+ metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
21
+ try {
22
+ const { appendSpend } = require('../utils/spend-ledger');
23
+ const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
24
+ // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
25
+ // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
26
+ appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
27
+ } catch { /* best-effort */ }
28
+ }
29
+ return { usage };
30
+ }
31
+
32
+ module.exports = { finalizeSpendForReopen };
@@ -251,7 +251,7 @@ async function resumeSidecar(options) {
251
251
  // v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
252
252
  // a ledger row (status: statusFromResult, matching start.js — not terminal.status).
253
253
  {
254
- const { finalizeSpendForReopen } = require('./continue');
254
+ const { finalizeSpendForReopen } = require('./reopen-spend');
255
255
  const { statusFromResult } = require('../utils/result-schema');
256
256
  const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
257
257
  const { usage } = finalizeSpendForReopen({
@@ -51,7 +51,11 @@ function createDefaultConfig(defaultModel) {
51
51
  const cfg = {
52
52
  ...existing,
53
53
  default: existing.default || defaultModel,
54
- aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
54
+ // Same restatement as the readline gate below (fix round 3, G-2): a spread
55
+ // into `{}` re-materialises Object.prototype. `saveConfig` rebuilds this
56
+ // into its own literal anyway, so this one is defense in depth rather than
57
+ // a measured hole — recorded as such rather than claimed as a fix.
58
+ aliases: { __proto__: null, ...getDefaultAliases(), ...(existing.aliases || {}) },
55
59
  };
56
60
  saveConfig(cfg);
57
61
  logger.info('Default config ensured', {
@@ -128,7 +132,14 @@ function resolveChoice(input, picks, catalog) {
128
132
  return { modelId: input };
129
133
  }
130
134
  const cfg = loadConfig();
131
- const aliases = { ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
135
+ // ⚠️ `__proto__: null` v4.8 SI-22.4 fix round 3 (council G-2). `input` here
136
+ // is FREE-FORM readline text, and the gate below is `aliases[input] !==
137
+ // undefined`, so on a plain literal `toString` / `valueOf` / `constructor` /
138
+ // `hasOwnProperty` all measured TRUE and returned `{alias: input, noUpgrade:
139
+ // true}` — setup accepted them as existing aliases. Spreading a
140
+ // null-prototype object into a bare `{}` produces a PLAIN object again, so the
141
+ // curated-models fix does not reach this literal; the seed has to be restated.
142
+ const aliases = { __proto__: null, ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
132
143
  if (aliases[input] !== undefined) {
133
144
  return { alias: input, noUpgrade: true };
134
145
  }
@@ -60,7 +60,16 @@ function saveConfig(configData) {
60
60
  if (configData && configData.aliases) {
61
61
  const cleaned = {};
62
62
  for (const [key, value] of Object.entries(configData.aliases)) {
63
- if (key === 'null' || !value || typeof value !== 'string' || value === 'null') {
63
+ // `key === '__proto__'` v4.8 SI-22.4 fix round 3 (council G-5). Without
64
+ // it the write below (`cleaned[key] = value`) hit Object.prototype's
65
+ // INHERITED `__proto__` setter, which ignores a string, so the alias
66
+ // vanished with NO "Removing invalid alias" notice — the only silent
67
+ // removal in this loop. Rejecting it explicitly puts it on the same
68
+ // footing as the `'null'` key beside it: same branch, same message.
69
+ // (No pollution was possible either way — only strings reach this line,
70
+ // and the setter ignores them — so this is an announcement fix, not a
71
+ // security one. Stated that way on purpose.)
72
+ if (key === 'null' || key === '__proto__' || !value || typeof value !== 'string' || value === 'null') {
64
73
  process.stderr.write(
65
74
  `Notice: Removing invalid alias '${key}' (value: ${JSON.stringify(value)}) from config.\n`
66
75
  );
@@ -78,7 +87,14 @@ function saveConfig(configData) {
78
87
 
79
88
  /** @returns {object} Copy of the default alias map */
80
89
  function getDefaultAliases() {
81
- return { ...DEFAULT_ALIASES };
90
+ // ⚠️ `__proto__: null` must be RESTATED here, not inherited. A spread into a
91
+ // bare `{}` literal produces a plain object again, so fixing the builders in
92
+ // curated-models.js does NOT reach this copy — measured, not assumed. Its
93
+ // consumers index it: `sidecar/setup.js:479` (`getDefaultAliases()[alias]`)
94
+ // and `electron/setup-ui-aliases.js :: buildAliasEditorHTML`
95
+ // (`aliases[key] !== undefined`).
96
+ // Named mutant "BUILDERPROTO" covers this line too.
97
+ return { __proto__: null, ...DEFAULT_ALIASES };
82
98
  }
83
99
 
84
100
  /**
@@ -216,7 +232,44 @@ function checkConfigChanged(currentHash) {
216
232
  function getEffectiveAliases() {
217
233
  const config = loadConfig();
218
234
  const userAliases = (config && config.aliases) || {};
219
- return { ...DEFAULT_ALIASES, ...userAliases };
235
+ // `__proto__: null` v4.8 SI-22.4 fix round 2 (council B1). This table is
236
+ // read with BARE INDEXING by five gates, so on a normal object a member
237
+ // literally named 'toString' / 'constructor' / 'valueOf' / 'hasOwnProperty'
238
+ // resolved off Object.prototype to a truthy Function and was treated as a
239
+ // KNOWN ALIAS. Measured, not argued:
240
+ // resolveModel('toString') -> the Function itself,
241
+ // typeof 'function', where every caller expects a model-id STRING
242
+ // (`:111` and `:142` gate on `!== undefined`, which a Function passes)
243
+ // classifyCouncilMembers(['toString '], []) -> ACCEPTED, i.e. runnable
244
+ // The SAME defect class this release already closed at other lookup tables —
245
+ // `tally.js :: VERDICTS`, `report.js :: SYMBOL`, `debate.js :: PAST_TENSE`
246
+ // (all `__proto__: null`), plus `street-cred.js :: perJudgeRank` and
247
+ // `report.js :: ROLE_SUFFIX` (both `Object.create(null)`, the same guarantee
248
+ // in the other spelling). The ALIAS table was not among them.
249
+ // ⚠️ SI-22.4 WIDENED it and that is why it is fixed here: at BASE the padded
250
+ // spelling ('toString ') missed the prototype and was correctly dropped;
251
+ // trimming before the lookup landed it on the inherited property. The
252
+ // unpadded spelling was already accepted, so restoring only the padded case
253
+ // would take more code AND deliberately preserve a known hole.
254
+ // ⚠️ Fixed HERE, not at the call sites — one line closes all five, each
255
+ // MEASURED at its own gate expression with `'toString'` (not inferred from
256
+ // this one): `resolveModel` `:111`/`:142` (`!== undefined` true→false) ·
257
+ // `classifyCouncilMembers` (accepted→dropped, end to end) ·
258
+ // `council/presets-cli.js:41` (`amicus council save`: unresolved false→true) ·
259
+ // `pack/pack-validate.js:71` (`seatOk` true→false) ·
260
+ // `utils/route-launch.js:205` (`isAlias` true→false).
261
+ // No consumer breaks: every reference either indexes (`aliases[key]`) or
262
+ // iterates own-enumerable keys (`Object.entries`/`Object.keys` —
263
+ // `buildProviderModels`, `formatAliasNames`, `mcp-tools.js :: getGuideText`,
264
+ // `sidecar/models.js :: aliasMarks`), and both behave identically on a
265
+ // null-prototype object. NOTHING calls a method ON the object — swept
266
+ // uncapped over `src/` and `electron/` for `aliases.<x>`, `in aliases`,
267
+ // `Object.values`, `JSON.stringify`, spread and `for…in`.
268
+ // A `__proto__` key inside the user's own config.json is copied as an ORDINARY
269
+ // own property by spread (never the setter), so the prototype stays null —
270
+ // measured on both a literal and a `JSON.parse`d source.
271
+ // Named mutant "PROTOALIASES": drop `__proto__: null` from the literal below.
272
+ return { __proto__: null, ...DEFAULT_ALIASES, ...userAliases };
220
273
  }
221
274
 
222
275
  /**
@@ -414,11 +467,42 @@ function getCouncilWithSource(name, catalog = []) {
414
467
  * at the last refresh; the leg itself fails pre-flight with the actionable
415
468
  * local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
416
469
  * catalog that omits the resolved id is a definitive drop.
417
- * @param {string[]} members raw council members (aliases or provider/model ids)
470
+ *
471
+ * WHITESPACE (v4.8 SI-22.4). Each member is TRIMMED before it is classified,
472
+ * closing a divergence: `--models` already trimmed
473
+ * (`sidecar/fanout-validate.js :: parseModelsList`, and `cli-council-run-bench.js
474
+ * :: parseList` on the council surface) while `--council` did not, so the same
475
+ * stray space was benign on one flag and, here, converted a typo into a dropped
476
+ * member and a degraded (2) exit. ⚠️ The dominant effect is RESURRECTION, not
477
+ * de-duplication: a padded member that is dropped today starts RUNNING, which
478
+ * is a new paid leg. Where the trim makes two members collide, the bench
479
+ * becomes a real twin and `seats.js :: buildSeats` mints `alias#N` for both.
480
+ * An all-whitespace member trims to `''`, which no alias table names, so gate 1
481
+ * below drops it — the `.filter(Boolean)` half of `parseModelsList`'s shape,
482
+ * reached without a third `reason` string (see the tripwire note below).
483
+ * @param {string[]} members council members as configured — aliases or
484
+ * provider/model ids, trimmed per member here. ⚠️ This is the only place the
485
+ * preset READ path trims — NOT the only place the preset path trims at all,
486
+ * and NOT the only trim a member meets. The WRITE side already trimmed:
487
+ * `council/presets-cli.js:34` (`amicus council save`) stores
488
+ * `.split(',').map(m => m.trim()).filter(Boolean)`, and the only other writer
489
+ * of `cfg.councils` in `src/` (`sidecar/setup.js:593`, the seeded `free`
490
+ * council) composes its members from generated/existing alias KEYS, which
491
+ * cannot carry user padding. So a padded member in `cfg.councils` comes from
492
+ * a hand-edited `config.json`, and that is the case this trim serves.
493
+ * Downstream, both council
494
+ * surfaces re-join the expanded bench and re-parse it downstream —
495
+ * `cli-handlers-fanout.js:91` → `:119` → `sidecar/fanout.js ::
496
+ * validateFanoutModels` → `parseModelsList`, and `mcp-council-run.js:177` →
497
+ * the spawned child's `cli-council-run-bench.js :: parseList` — so a member
498
+ * that somehow kept padding past this point would still be trimmed there
418
499
  * @param {Array<{id:string}>} [catalog]
419
500
  * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
420
501
  * `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
421
502
  * callers keep working); `droppedMembers` additively pairs each with WHY.
503
+ * ⚠️ Both report the member RAW — untrimmed, byte-for-byte as configured
504
+ * (v4.8 SI-22.4, R22.4-2) — so a user can find the offending string in their
505
+ * own config. Only `models` carries the trimmed value.
422
506
  *
423
507
  * Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
424
508
  * (that is the real key — BACKLOG.md's description of this shape had drifted to
@@ -442,18 +526,29 @@ function classifyCouncilMembers(members, catalog = []) {
442
526
  const models = [];
443
527
  const dropped = [];
444
528
  const droppedMembers = [];
445
- for (const member of members) {
529
+ for (const raw of members) {
530
+ // v4.8 SI-22.4. Trim BEFORE gate 1 below, never after: a padded ALIAS
531
+ // ('gpt ') must reach the alias table as written in the table, and a padded
532
+ // full id ('openai/gpt-5 ') must reach the catalog lookup clean. Trimming
533
+ // downstream of either gate would leave both misses in place. Non-strings
534
+ // pass through untouched so their `.includes` still throws exactly as it
535
+ // did before this line existed. Named mutant "NOTRIM": drop the `.trim()`.
536
+ const member = typeof raw === 'string' ? raw.trim() : raw;
446
537
  const id = member.includes('/') ? member : aliases[member];
538
+ // R22.4-2: `models` gets the TRIMMED value, `dropped`/`droppedMembers` get
539
+ // `raw` — a member still dropped after trimming is reported as the user
540
+ // wrote it, or they cannot grep their own config for it. Named mutant
541
+ // "TRIMDROPPED": report `member` instead of `raw` in the two drop branches.
447
542
  if (!id) { // alias no longer resolves
448
- dropped.push(member);
449
- droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
543
+ dropped.push(raw);
544
+ droppedMembers.push({ member: raw, reason: 'alias no longer resolves to a known model' });
450
545
  continue;
451
546
  }
452
547
  const vendor = typeof id === 'string' ? id.split('/')[0] : '';
453
548
  if (isLocalProvider(vendor)) { models.push(member); continue; }
454
549
  if (known.size > 0 && !known.has(id)) { // delisted model
455
- dropped.push(member);
456
- droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
550
+ dropped.push(raw);
551
+ droppedMembers.push({ member: raw, reason: 'resolved id is not present in the cached model catalog' });
457
552
  continue;
458
553
  }
459
554
  models.push(member);
@@ -465,8 +560,11 @@ function classifyCouncilMembers(members, catalog = []) {
465
560
  * Expand a saved council into a runnable members list, degrading gracefully.
466
561
  * Unresolvable aliases and delisted ids are dropped with a warning rather than
467
562
  * fail-fast-aborting the whole wave (classification: classifyCouncilMembers
468
- * above). Returns members RAW (alias or id) leg-time validation resolves
469
- * them again.
563
+ * above). Returns members UNRESOLVED (the alias or id as configured, never the
564
+ * id an alias maps to) — leg-time validation resolves them again. ⚠️ Not
565
+ * byte-identical to the configured string since v4.8 SI-22.4: classification
566
+ * trims each member, so `models[i]` is the configured member minus any
567
+ * surrounding whitespace. `dropped`/`droppedMembers` still carry it raw.
470
568
  *
471
569
  * Resolution order: user config (`config.councils`) is checked first; when
472
570
  * `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
@@ -111,6 +111,13 @@ const CARDLESS = [
111
111
  { alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
112
112
  { alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
113
113
  { alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
114
+ // inkling added 2026-08-14: the council-review workflow's default bench
115
+ // names it, and a workflow can only use aliases this table ships — a CI
116
+ // runner has no user config, so a locally-defined alias resolves to
117
+ // nothing there. Pinned to the full model, not `inkling-small`: the bench
118
+ // seat wants the flagship's judgment. `:batch` is deliberately not pinned
119
+ // (deferred completion is wrong for an interactive council leg).
120
+ { alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
114
121
  ];
115
122
 
116
123
  /**
@@ -242,7 +249,14 @@ function directFormProvenance() {
242
249
  * without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
243
250
  */
244
251
  function toGatewayRoutes() {
245
- const out = {};
252
+ // `__proto__: null` — v4.8 SI-22.4 round 3 (G-1). Read by BARE INDEXING
253
+ // downstream, so a plain `{}` let an alias named 'toString'/'constructor'/
254
+ // 'valueOf'/'hasOwnProperty' resolve off Object.prototype to a truthy
255
+ // Function — including on the auto-repair path (`alias-resolver.js ::
256
+ // autoRepairAlias`), which `getEffectiveAliases`'s own fix could never reach.
257
+ // Full measurement + why THREE seeds were needed:
258
+ // tests/council/preset-trim-mutants.js :: BUILDERPROTO (the named mutant).
259
+ const out = { __proto__: null };
246
260
  for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
247
261
  for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
248
262
  return out;
@@ -262,7 +276,8 @@ function toGatewayRoutes() {
262
276
  * shipped defaults.
263
277
  */
264
278
  function toDefaultAliases() {
265
- const out = {};
279
+ // `__proto__: null` — see toGatewayRoutes above. Becomes DEFAULT_ALIASES.
280
+ const out = { __proto__: null };
266
281
  for (const [alias, routes] of Object.entries(toGatewayRoutes())) {
267
282
  out[alias] = routes.direct || routes.openrouter;
268
283
  }
@@ -17,6 +17,11 @@ 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.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
21
+ // returned legs but none its own; a returned leg matching no roster slot; and (T5.5, `-rv` only)
22
+ // a leg that DID match a slot but whose join key names no judge the wave launched.
23
+ // Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
24
+ 'seat-unbound',
20
25
  'internal',
21
26
  // doctor channels
22
27
  'doctor-check-failed', 'doctor-fix',
@@ -48,6 +48,38 @@ function evaluateEngineInstalls(d) {
48
48
 
49
49
  const broken = npxCopies.filter((i) => !i.engineOk);
50
50
  if (broken.length === 0) {
51
+ // Version skew (#133): a PRESENT engine can still be the wrong one. The
52
+ // npx copies and the global install resolve independently and at different
53
+ // times, and two versions writing one shared opencode.db is what produced
54
+ // #133's SQLiteError. Compare npx against global ONLY — a genuine source
55
+ // checkout's engine legitimately differs, so including it would fire red
56
+ // on every developer machine and in CI (E-1c). Unresolved versions never
57
+ // signal skew; absence of evidence is not evidence.
58
+ // WARN, never ERROR: doctor --fix has no skew branch, so an error would be
59
+ // unfixable, and this file already downgrades to warn at the ambiguous-npx
60
+ // branch below whenever the copy npx will select is ambiguous.
61
+ //
62
+ // Review round 2, finding 1: `kind === 'global'` alone is NOT the whole
63
+ // baseline. On the documented end-user invocation — `amicus doctor` run
64
+ // from the globally-installed copy — the running process IS the global
65
+ // install, so engine-install-scan.js's own dedup drops the separate
66
+ // `global` record and only `kind:'running'` survives, carrying
67
+ // `isGlobal: true` instead (see scanEngineInstalls). Without the
68
+ // `|| i.isGlobal` clause, the skew check was structurally unable to fire
69
+ // for exactly the users who filed #133. A TRUE source checkout (running,
70
+ // no isGlobal) is still excluded, as intended.
71
+ const globalV = (installs.find((i) => i.kind === 'global' || i.isGlobal) || {}).engineVersion;
72
+ const skewed = globalV
73
+ ? npxCopies.filter((i) => i.engineVersion && i.engineVersion !== globalV)
74
+ : [];
75
+ if (skewed.length > 0) {
76
+ const detail = skewed.map((i) => `${i.pkgDir} has ${i.engineVersion}`).join('; ');
77
+ return {
78
+ id, name, status: 'warn',
79
+ message: `engine version skew — global install has ${globalV}; ${detail}`,
80
+ hint: HINTS.engineVersionSkew,
81
+ };
82
+ }
51
83
  return {
52
84
  id, name, status: 'ok',
53
85
  message: `engine present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,