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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- 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
|
-
|
|
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 = {
|
|
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 };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
32
|
-
*
|
|
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 };
|
package/src/sidecar/resume.js
CHANGED
|
@@ -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('./
|
|
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({
|
package/src/sidecar/setup.js
CHANGED
|
@@ -51,7 +51,11 @@ function createDefaultConfig(defaultModel) {
|
|
|
51
51
|
const cfg = {
|
|
52
52
|
...existing,
|
|
53
53
|
default: existing.default || defaultModel,
|
|
54
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/degrade.js
CHANGED
|
@@ -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')}`,
|