amicus 4.9.7 → 4.10.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 +125 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/architecture-map.md +41 -6
- package/docs/configuration.md +14 -8
- package/docs/council.md +140 -3
- package/docs/usage.md +29 -6
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli-handlers.js +8 -1
- package/src/cli.js +34 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/aliases-review-gate.js +65 -0
- package/src/sidecar/aliases-review-prompt.js +91 -0
- package/src/sidecar/aliases-review-render.js +116 -0
- package/src/sidecar/aliases-review.js +298 -0
- package/src/sidecar/aliases.js +279 -0
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/models.js +20 -7
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/setup.js +20 -18
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/alias-groups.js +128 -0
- package/src/utils/alias-proposals.js +151 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/alias-state.js +88 -0
- package/src/utils/alias-store.js +65 -0
- package/src/utils/config.js +10 -5
- package/src/utils/degrade.js +8 -0
- package/src/utils/model-id-siblings.js +106 -0
- package/src/utils/model-validator.js +1 -1
- package/src/utils/quick-picks.js +13 -32
- package/src/utils/text-sanitize.js +27 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/aliases-review
|
|
3
|
+
* `amicus aliases --review` (#238 §4): a numbered readline picker over the
|
|
4
|
+
* engine's proposals — no copy-paste anywhere. Accept writes the chosen id and
|
|
5
|
+
* the encoding decides the state (Q4): the shipped pin → `removeAlias`
|
|
6
|
+
* (follows); anything else → `addAlias` (pinned). Without a TTY it refuses
|
|
7
|
+
* loudly (Q2): the list, one reason line, exit 1. The §5 WRITE gate lives
|
|
8
|
+
* here: accepting a catalog-vouched id needs a fresh catalog (24 h); `follow`
|
|
9
|
+
* is exempt because it removes a key. Screen text is aliases-review-render.js
|
|
10
|
+
* and the readline prompt is aliases-review-prompt.js (both split out to hold
|
|
11
|
+
* the 300-line gate). Every alias/id/key written to the terminal — including
|
|
12
|
+
* a caught `err.message`, via `collapseExcerpt` (a sentence, not an id) —
|
|
13
|
+
* rides the house sanitizer first (`utils/text-sanitize.js`, #249 r2 C4).
|
|
14
|
+
*
|
|
15
|
+
* Fix round 1: a missing cache reads as its own banner, never a bogus
|
|
16
|
+
* multi-thousand-day `ageLabel`; an aborted prompt — Ctrl-D/EOF (readline's
|
|
17
|
+
* `close`) or, IN A RAW-MODE TERMINAL (stdout a TTY), Ctrl-C (readline's own
|
|
18
|
+
* `SIGINT`, #249 r2 D1) — rejects the pending `ask` with a `REVIEW_ABORTED`
|
|
19
|
+
* sentinel rather than silently exiting 0 (piped stdout: `terminal` defaults
|
|
20
|
+
* false, so Ctrl-C is then the ordinary process signal — #249 r2 review
|
|
21
|
+
* F6/F9); every config write is caught per-call so a failure reports and
|
|
22
|
+
* re-shows the menu; typing the shipped id into "choose another" follows unconditionally
|
|
23
|
+
* (Q4's encoding, #249 r2 A1/C2) rather than pinning a redundant copy or
|
|
24
|
+
* consulting either gate; a taken notable name gets a numeric suffix
|
|
25
|
+
* (`freeSuffix`), deliberately not `deriveFreeAlias`'s `free-` naming,
|
|
26
|
+
* which is for free-tier council seeds.
|
|
27
|
+
* Fix round 2 (#249 r1) gate helpers (§5-gated "choose another", clock-skew
|
|
28
|
+
* freshness, a throwing `readCache`) live in aliases-review-gate.js.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { DEFAULT_MAX_AGE_MS } = require('../utils/model-catalog');
|
|
34
|
+
const { stripGatewayPrefix } = require('../utils/curated-models');
|
|
35
|
+
const { gatedCatalogIds } = require('../utils/alias-proposals');
|
|
36
|
+
const { safeFragment, collapseExcerpt } = require('../utils/text-sanitize');
|
|
37
|
+
const { menuFor, menuLineText, renderScreen, refreshingCatalogLine } = require('./aliases-review-render');
|
|
38
|
+
const { classifyTypedId, notInCatalogLine, notVerifiedLine, staleCatalogBanner } = require('./aliases-review-gate');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Real-CLI collaborators. Requires are lazy/function-scoped (not top-level)
|
|
42
|
+
* because `aliases.js :: handleAliases` requires THIS module to dispatch
|
|
43
|
+
* `--review` — a top-level `require('./aliases')` here would be a load-time
|
|
44
|
+
* cycle.
|
|
45
|
+
* @returns {object} collaborators for `runReview` when the caller supplies none
|
|
46
|
+
*/
|
|
47
|
+
function defaultDeps() {
|
|
48
|
+
const base = require('./aliases');
|
|
49
|
+
const d = base.loadDeps();
|
|
50
|
+
return {
|
|
51
|
+
...d,
|
|
52
|
+
isTTY: !!process.stdin.isTTY,
|
|
53
|
+
write: (s) => process.stdout.write(s),
|
|
54
|
+
stderr: (s) => process.stderr.write(s),
|
|
55
|
+
collectAliasView: (opts) => base.collectAliasView(opts, d),
|
|
56
|
+
renderAliasList: (view) => base.renderAliasList(view, d.groupAliases),
|
|
57
|
+
addAlias: require('./setup').addAlias,
|
|
58
|
+
removeAlias: require('../utils/alias-store').removeAlias,
|
|
59
|
+
recordDismissal: require('../utils/alias-store').recordDismissal,
|
|
60
|
+
effectiveAliasNames: () => new Set(Object.keys(d.config.getEffectiveAliases())),
|
|
61
|
+
now: () => Date.now(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The §5 WRITE gate (mirrors doctor-alias-check.js's unexported
|
|
67
|
+
* `isCatalogFresh`). R3: `age >= 0` is required too, so a future `fetchedAt`
|
|
68
|
+
* (clock skew) is explicitly not fresh rather than indefinitely so.
|
|
69
|
+
* @returns {boolean} true when `fetchedAt` is a number, not in the future, and no older than 24h
|
|
70
|
+
*/
|
|
71
|
+
function isFresh(fetchedAt, now) {
|
|
72
|
+
return typeof fetchedAt === 'number' && (now - fetchedAt) >= 0 && (now - fetchedAt) <= DEFAULT_MAX_AGE_MS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns {boolean} true when two ids name the same model once gateway prefixes are normalized */
|
|
76
|
+
function sameModel(a, b) {
|
|
77
|
+
return typeof a === 'string' && typeof b === 'string' && stripGatewayPrefix(a) === stripGatewayPrefix(b);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @returns {string} `base`, or the first `${base}-2`, `${base}-3`, … not already in `taken` */
|
|
81
|
+
function freeSuffix(base, taken) {
|
|
82
|
+
if (!taken.has(base)) { return base; }
|
|
83
|
+
let n = 2;
|
|
84
|
+
while (taken.has(`${base}-${n}`)) { n += 1; }
|
|
85
|
+
return `${base}-${n}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Accept one candidate. `follow` unpins (`removeAlias` — the encoding IS the
|
|
90
|
+
* state, Q4); `notable` pins under a numeric-suffixed name when its suggested
|
|
91
|
+
* alias is already taken; anything else pins the alias straight to the
|
|
92
|
+
* candidate id. Any write failure is caught here (M2) so the caller can
|
|
93
|
+
* re-show the menu instead of crashing the review.
|
|
94
|
+
* @returns {boolean} true on a successful write
|
|
95
|
+
*/
|
|
96
|
+
function acceptCandidate(p, c, d) {
|
|
97
|
+
try {
|
|
98
|
+
if (c.why === 'follow') {
|
|
99
|
+
d.removeAlias(p.alias);
|
|
100
|
+
d.write(` ✓ ${safeFragment(p.alias)} now follows the shipped recommendation (${safeFragment(c.id)})\n`);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (c.why === 'notable') {
|
|
104
|
+
const name = freeSuffix(p.alias, d.effectiveAliasNames());
|
|
105
|
+
d.addAlias(name, c.id);
|
|
106
|
+
d.write(` ✓ ${safeFragment(name)} → ${safeFragment(c.id)} (pinned)\n`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
d.addAlias(p.alias, c.id);
|
|
110
|
+
d.write(` ✓ ${safeFragment(p.alias)} → ${safeFragment(c.id)} (pinned)\n`);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
d.write(` could not write: ${collapseExcerpt(err.message)}\n`);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The "choose another" sub-flow: a free-text model id. Typing the shipped id
|
|
120
|
+
* (M3, #249 r2 A1/C2) IS the menu's `follow` action, checked FIRST and exempt
|
|
121
|
+
* from BOTH gates below, even when the catalog is stale or lacks the shipped
|
|
122
|
+
* id -- the numbered `follow` item is offered regardless of the catalog.
|
|
123
|
+
* Anything else is checked against the §5 display gate (R2 — the SAME
|
|
124
|
+
* `gatedCatalogIds` the menu's own candidates come from) then the freshness
|
|
125
|
+
* gate; a write failure (M2) is a cancel/refusal.
|
|
126
|
+
* @returns {Promise<'accepted'|'cancel'|'refused'|'error'>}
|
|
127
|
+
*/
|
|
128
|
+
async function chooseAnother(p, ctx) {
|
|
129
|
+
const { d, fresh, allCatalogIds, gatedIds, ask } = ctx;
|
|
130
|
+
for (;;) {
|
|
131
|
+
const raw = await ask(' model id (provider/model), blank to cancel: ');
|
|
132
|
+
const ans = String(raw || '').trim();
|
|
133
|
+
if (!ans) { return 'cancel'; }
|
|
134
|
+
// Mutant GATEFIRST: move this below the two gates and the R1 tests go red.
|
|
135
|
+
const follows = !!(p.shipped && sameModel(ans, p.shipped));
|
|
136
|
+
if (!follows) {
|
|
137
|
+
const status = classifyTypedId(ans, allCatalogIds, gatedIds);
|
|
138
|
+
if (status === 'unknown') { d.write(notInCatalogLine(ans)); continue; }
|
|
139
|
+
if (status === 'ungated') { d.write(notVerifiedLine(ans)); continue; }
|
|
140
|
+
if (!fresh) {
|
|
141
|
+
d.write(' cannot accept: the catalog is not fresh (see above)\n');
|
|
142
|
+
return 'refused';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (follows) {
|
|
147
|
+
d.removeAlias(p.alias);
|
|
148
|
+
d.write(` ✓ ${safeFragment(p.alias)} now follows the shipped recommendation (${safeFragment(ans)})\n`);
|
|
149
|
+
} else {
|
|
150
|
+
d.addAlias(p.alias, ans);
|
|
151
|
+
d.write(` ✓ ${safeFragment(p.alias)} → ${safeFragment(ans)} (pinned)\n`);
|
|
152
|
+
}
|
|
153
|
+
return 'accepted';
|
|
154
|
+
} catch (err) {
|
|
155
|
+
d.write(` could not write: ${collapseExcerpt(err.message)}\n`);
|
|
156
|
+
return 'error';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drive one proposal's screen + menu loop until it is accepted, skipped, or
|
|
163
|
+
* dismissed. A refused accept (stale catalog), a write failure, or a
|
|
164
|
+
* cancelled/refused "choose another" redisplays this SAME proposal's menu
|
|
165
|
+
* rather than advancing. A menu answer must be all-digits before it is ever
|
|
166
|
+
* handed to `Number` (M6).
|
|
167
|
+
* @returns {Promise<'accepted'|'skipped'|'dismissed'>}
|
|
168
|
+
*/
|
|
169
|
+
async function reviewOne(p, i, n, ctx) {
|
|
170
|
+
const { d, fresh, ask } = ctx;
|
|
171
|
+
const items = menuFor(p);
|
|
172
|
+
d.write(renderScreen(p, i, n, items));
|
|
173
|
+
for (;;) {
|
|
174
|
+
const raw = await ask(' > ');
|
|
175
|
+
const trimmed = String(raw || '').trim();
|
|
176
|
+
const choice = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
|
|
177
|
+
if (!Number.isInteger(choice) || choice < 1 || choice > items.length) {
|
|
178
|
+
d.write(` choose 1-${items.length}\n`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const item = items[choice - 1];
|
|
182
|
+
if (item.action === 'skip') { return 'skipped'; }
|
|
183
|
+
if (item.action === 'dismiss') {
|
|
184
|
+
try {
|
|
185
|
+
d.recordDismissal(p.dismissKey);
|
|
186
|
+
d.write(` dismissed ${safeFragment(p.dismissKey)}\n`);
|
|
187
|
+
return 'dismissed';
|
|
188
|
+
} catch (err) {
|
|
189
|
+
d.write(` could not write: ${collapseExcerpt(err.message)}\n`);
|
|
190
|
+
d.write(menuLineText(items) + '\n');
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (item.action === 'choose') {
|
|
195
|
+
const outcome = await chooseAnother(p, ctx);
|
|
196
|
+
if (outcome === 'accepted') { return 'accepted'; }
|
|
197
|
+
d.write(menuLineText(items) + '\n'); // 'cancel', 'refused' or 'error': same proposal's menu again
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!fresh && item.candidate.why !== 'follow') {
|
|
201
|
+
d.write(' cannot accept: the catalog is not fresh (see above)\n');
|
|
202
|
+
d.write(menuLineText(items) + '\n');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (acceptCandidate(p, item.candidate, d)) { return 'accepted'; }
|
|
206
|
+
d.write(menuLineText(items) + '\n');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `amicus aliases --review` entry point.
|
|
212
|
+
* @param {object} args parsed CLI args (the --json/--quiet argument-error
|
|
213
|
+
* check happens in aliases.js before this is ever called)
|
|
214
|
+
* @param {object} [deps] injectable collaborators — see the module docblock;
|
|
215
|
+
* defaults to real I/O (readline over stdin/stdout) when omitted
|
|
216
|
+
* @returns {Promise<number>} 1 when refused for lacking a TTY or interrupted, else 0
|
|
217
|
+
*/
|
|
218
|
+
async function runReview(args, deps) {
|
|
219
|
+
// F4b: merge (not replace), so a test can inject only the members it cares
|
|
220
|
+
// about and let every other collaborator run for real against the hermetic
|
|
221
|
+
// scratch config -- additive, so every existing deps-object test still works.
|
|
222
|
+
const d = { ...defaultDeps(), ...(deps || {}) };
|
|
223
|
+
let prompt = null;
|
|
224
|
+
let ask = d.ask; // M6: kept local, never written back onto `d`
|
|
225
|
+
try {
|
|
226
|
+
const isTTY = d.isTTY ?? !!process.stdin.isTTY;
|
|
227
|
+
if (!isTTY) {
|
|
228
|
+
const view = await d.collectAliasView({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
229
|
+
d.write(d.renderAliasList(view));
|
|
230
|
+
d.stderr('aliases --review is interactive: run it in a terminal, or use `amicus aliases --json` for machine output\n');
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
if (!ask) {
|
|
234
|
+
// Lazy require, same as `./aliases` above -- but for locality, not a
|
|
235
|
+
// cycle: aliases-review-prompt.js has no require edge back to this
|
|
236
|
+
// file, so a top-level require would be equally safe here.
|
|
237
|
+
const { createPrompt } = require('./aliases-review-prompt');
|
|
238
|
+
prompt = createPrompt();
|
|
239
|
+
ask = prompt.ask;
|
|
240
|
+
}
|
|
241
|
+
// Minor (spec §4): name the inline refresh wait so it doesn't read as a
|
|
242
|
+
// hang. R7: a throwing readCache (disk error, corrupt cache) drops this
|
|
243
|
+
// best-effort banner rather than crashing the review.
|
|
244
|
+
if (typeof d.readCache === 'function') {
|
|
245
|
+
try { const line = refreshingCatalogLine(d.readCache(), Date.now()); if (line) { d.write(line); } }
|
|
246
|
+
catch { /* best-effort banner only */ }
|
|
247
|
+
}
|
|
248
|
+
const view = await d.collectAliasView({});
|
|
249
|
+
// F1: no catalog at all means no proposal was ever judged -- that is not
|
|
250
|
+
// the same fact as "judged them all, nothing to review" (below), so it
|
|
251
|
+
// gets its own refusal, before that check ever runs.
|
|
252
|
+
if (!view.catalogAvailable) {
|
|
253
|
+
d.write(' no catalog — cannot review; run amicus models --refresh\n');
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
const now = (d.now || Date.now)();
|
|
257
|
+
const fetchedAt = view.catalogInfo && view.catalogInfo.fetchedAt;
|
|
258
|
+
const fresh = isFresh(fetchedAt, now);
|
|
259
|
+
const proposals = Array.isArray(view.proposals) ? view.proposals : [];
|
|
260
|
+
if (proposals.length === 0) {
|
|
261
|
+
d.write(` Nothing to review — ${(view.rows || []).length} aliases, all following or up to date.\n`);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
// M5: the stale/no-cache/clock-skew banner only ever prints once there is
|
|
265
|
+
// something to act on — a "nothing to review" run never mentions the catalog.
|
|
266
|
+
if (!fresh) { d.write(staleCatalogBanner(fetchedAt, now)); }
|
|
267
|
+
let accepted = 0;
|
|
268
|
+
let skipped = 0;
|
|
269
|
+
let dismissed = 0;
|
|
270
|
+
// R2: computed once for the whole run (the catalog view is fixed for the
|
|
271
|
+
// session) so `chooseAnother` never re-derives them per keystroke.
|
|
272
|
+
const catalogModels = Array.isArray(view.catalogInfo && view.catalogInfo.models) ? view.catalogInfo.models : [];
|
|
273
|
+
const allCatalogIds = new Set(catalogModels.map(m => m && m.id).filter(Boolean));
|
|
274
|
+
const gatedIds = new Set(gatedCatalogIds(view.catalogInfo));
|
|
275
|
+
const ctx = { d, fresh, ask, allCatalogIds, gatedIds };
|
|
276
|
+
try {
|
|
277
|
+
for (let i = 0; i < proposals.length; i++) {
|
|
278
|
+
const outcome = await reviewOne(proposals[i], i, proposals.length, ctx);
|
|
279
|
+
if (outcome === 'accepted') { accepted++; }
|
|
280
|
+
else if (outcome === 'dismissed') { dismissed++; }
|
|
281
|
+
else { skipped++; }
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err && err.code === 'REVIEW_ABORTED') {
|
|
285
|
+
d.write(` review interrupted — ${accepted} accepted, ${skipped} skipped, ${dismissed} dismissed so far\n`);
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
const n = proposals.length;
|
|
291
|
+
d.write(` Reviewed ${n} proposal${n === 1 ? '' : 's'}: ${accepted} accepted, ${skipped} skipped, ${dismissed} dismissed.\n`);
|
|
292
|
+
return 0;
|
|
293
|
+
} finally {
|
|
294
|
+
if (prompt) { prompt.close(); }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = { runReview };
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/aliases
|
|
3
|
+
* `amicus aliases` (#238 D4) — the user's alias map as a standing command.
|
|
4
|
+
*
|
|
5
|
+
* amicus aliases list: following / pinned, grouped by vendor
|
|
6
|
+
* amicus aliases --review picker over every proposal (aliases-review.js)
|
|
7
|
+
* amicus aliases --json versioned document: rows + proposals
|
|
8
|
+
*
|
|
9
|
+
* The LIST reads the catalog CACHE at any age and never networks (§5 display
|
|
10
|
+
* gate); the picker refreshes inline when the cache is stale (write gate).
|
|
11
|
+
* Every form normalizes the config on entry (D6), best-effort.
|
|
12
|
+
*
|
|
13
|
+
* #249 r2 C4: `renderAliasList`'s alias names, ids and vendor-group labels
|
|
14
|
+
* (an unmapped vendor's label is `titleCaseVendor` of a config VALUE's
|
|
15
|
+
* segment — still third-party, per review F1), and the typed name in
|
|
16
|
+
* `handleUnpin`'s messages, are quoted onto a terminal and ride `safeFragment`
|
|
17
|
+
* (the house sanitizer, `utils/text-sanitize.js`) — the fragment, never the
|
|
18
|
+
* composed line, per `alias-shadow.js :: formatAliasShadow`'s rule. A caught
|
|
19
|
+
* `err.message` is a sentence, not an id, so it rides `collapseExcerpt` at
|
|
20
|
+
* the house default cap instead (the `describeThrown` precedent).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { SCHEMA_VERSION } = require('../utils/result-schema-version');
|
|
26
|
+
const { DEFAULT_MAX_AGE_MS } = require('../utils/model-catalog');
|
|
27
|
+
const { stripGatewayPrefix } = require('../utils/curated-models');
|
|
28
|
+
const { safeFragment, collapseExcerpt } = require('../utils/text-sanitize');
|
|
29
|
+
|
|
30
|
+
/** @returns {object} this module's collaborators, gathered so a caller can override them in tests */
|
|
31
|
+
function loadDeps() {
|
|
32
|
+
const config = require('../utils/config');
|
|
33
|
+
return {
|
|
34
|
+
config,
|
|
35
|
+
normalizeAliases: require('../utils/alias-state').normalizeAliases,
|
|
36
|
+
listAliasRows: require('../utils/alias-state').listAliasRows,
|
|
37
|
+
buildAliasProposals: require('../utils/alias-proposals').buildAliasProposals,
|
|
38
|
+
readDismissals: require('../utils/alias-store').readDismissals,
|
|
39
|
+
getCatalogInfo: require('../utils/model-catalog').getCatalogInfo,
|
|
40
|
+
readCache: require('../utils/model-catalog').readCache,
|
|
41
|
+
groupAliases: require('../utils/alias-groups').groupAliases,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns {boolean} true when an own value of `aliases` is not a non-empty string -- saveConfig's own stripper would remove it */
|
|
46
|
+
function hasStrippableAliasValue(aliases) {
|
|
47
|
+
return Object.keys(aliases).some(k => typeof aliases[k] !== 'string' || aliases[k].length === 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize on entry (D6), best-effort: a read-only config dir never blocks a
|
|
52
|
+
* listing — the in-memory normalized view is used and the failure announced.
|
|
53
|
+
* Also fires on a non-string value (#249 r1 R8a): `normalizeAliases` only
|
|
54
|
+
* drops a value equal to the shipped default, so a garbage value would
|
|
55
|
+
* otherwise sit on disk forever -- `saveConfig`'s own stripper removes it,
|
|
56
|
+
* with its own Notice.
|
|
57
|
+
* @returns {object} the user alias map after normalization
|
|
58
|
+
*/
|
|
59
|
+
function normalizeOnEntry(d) {
|
|
60
|
+
const cfg = d.config.loadConfig();
|
|
61
|
+
const defaults = d.config.getDefaultAliases();
|
|
62
|
+
if (!cfg || !cfg.aliases || typeof cfg.aliases !== 'object') { return {}; }
|
|
63
|
+
const probe = d.normalizeAliases(cfg.aliases, defaults);
|
|
64
|
+
if (probe.removed.length > 0 || hasStrippableAliasValue(cfg.aliases)) {
|
|
65
|
+
try { d.config.saveConfig(cfg); } // saveConfig prints the Notices
|
|
66
|
+
catch (err) { process.stderr.write(`Notice: could not normalize aliases (${collapseExcerpt(err.message)}) — keys left on disk; every alias still resolves to the same id\n`); }
|
|
67
|
+
}
|
|
68
|
+
return probe.aliases;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {{maxAgeMs?: number}} [opts] `Number.POSITIVE_INFINITY` = cache only
|
|
73
|
+
* @returns {Promise<{rows: Array, proposals: Array, catalogInfo: object, catalogAvailable: boolean}>}
|
|
74
|
+
*/
|
|
75
|
+
async function collectAliasView(opts = {}, d = loadDeps()) {
|
|
76
|
+
const userAliases = normalizeOnEntry(d);
|
|
77
|
+
const defaults = d.config.getDefaultAliases();
|
|
78
|
+
let catalogInfo = { models: [], fetchedAt: null, providerFailures: [] };
|
|
79
|
+
try {
|
|
80
|
+
if (opts.maxAgeMs === Number.POSITIVE_INFINITY) {
|
|
81
|
+
// Display gate (§5): the LIST never networks, even to fill an empty or
|
|
82
|
+
// v1 cache — read whatever is on disk, verbatim, with no freshness
|
|
83
|
+
// check at all. `getCatalogInfo` cannot be reused here: it always
|
|
84
|
+
// calls `getCatalog`, which refreshes (a real fetch) the moment
|
|
85
|
+
// `readCache()` returns null, regardless of `maxAgeMs`.
|
|
86
|
+
const c = d.readCache();
|
|
87
|
+
catalogInfo = {
|
|
88
|
+
models: (c && Array.isArray(c.models)) ? c.models : [],
|
|
89
|
+
fetchedAt: c && typeof c.fetchedAt === 'number' ? c.fetchedAt : null,
|
|
90
|
+
providerFailures: (c && Array.isArray(c.providerFailures)) ? c.providerFailures : [],
|
|
91
|
+
};
|
|
92
|
+
} else {
|
|
93
|
+
// The picker's default-age path (Task 8): a stale cache refreshes inline.
|
|
94
|
+
catalogInfo = await d.getCatalogInfo(opts.maxAgeMs === undefined ? {} : { maxAgeMs: opts.maxAgeMs });
|
|
95
|
+
}
|
|
96
|
+
} catch (err) { process.stderr.write(`Notice: catalog unavailable (${collapseExcerpt(err.message)}) — no proposals\n`); }
|
|
97
|
+
const rows = d.listAliasRows(userAliases, defaults);
|
|
98
|
+
const proposals = d.buildAliasProposals({ userAliases, defaults, catalogInfo, dismissed: d.readDismissals() });
|
|
99
|
+
return { rows, proposals, catalogInfo, catalogAvailable: (catalogInfo.models || []).length > 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* F2: the per-row flag names the SPECIFIC reason a proposal exists, instead
|
|
104
|
+
* of a blanket "newer available" that was wrong for two of the three kinds —
|
|
105
|
+
* an ahead-of-shipped pin with no sibling only differs from the shipped pin,
|
|
106
|
+
* and a stale pin with no catalog match at all is gone from the catalog
|
|
107
|
+
* outright, neither of which is "newer available".
|
|
108
|
+
* @param {string[]} reasons a proposal's `reasons` array
|
|
109
|
+
* @returns {string} the row suffix, or '' when called with no reasons
|
|
110
|
+
*/
|
|
111
|
+
function rowFlag(reasons) {
|
|
112
|
+
if (!reasons || reasons.length === 0) { return ''; }
|
|
113
|
+
if (reasons.includes('newer-sibling')) { return ' ⚠ newer available'; }
|
|
114
|
+
if (reasons.includes('stale')) { return ' ⚠ gone from catalog'; }
|
|
115
|
+
return ' differs from shipped';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* R4 (#249 r1 C1): a pinned curated row that names the shipped model under a
|
|
120
|
+
* DIFFERENT gateway form gets no proposal at all -- alias-proposals.js's own
|
|
121
|
+
* `sameModel` already treats it as identical to the shipped pin, so it is
|
|
122
|
+
* neither stale nor "differing". Without this note it renders identically
|
|
123
|
+
* to an arbitrary custom pin, losing the fact that it is the shipped
|
|
124
|
+
* recommendation in a different form. Truthful transparency, not a warning.
|
|
125
|
+
* @param {{state:string, curated:boolean, id:string, shipped:string|null}} r
|
|
126
|
+
* @returns {string} the row suffix, or '' when the note does not apply
|
|
127
|
+
*/
|
|
128
|
+
function sameGatewayNote(r) {
|
|
129
|
+
if (r.state !== 'pinned' || !r.curated || typeof r.id !== 'string' || typeof r.shipped !== 'string') { return ''; }
|
|
130
|
+
if (r.id === r.shipped) { return ''; }
|
|
131
|
+
return stripGatewayPrefix(r.id) === stripGatewayPrefix(r.shipped) ? ' same model as shipped, other gateway' : '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {{rows: Array, proposals: Array}} view @param {Function} [groupAliases] injectable for tests; defaults to loadDeps().groupAliases so the published `(view) => string` signature works standalone @returns {string} */
|
|
135
|
+
function renderAliasList(view, groupAliases = loadDeps().groupAliases) {
|
|
136
|
+
const byAlias = new Map(view.rows.map(r => [r.alias, r]));
|
|
137
|
+
const proposalByAlias = new Map(view.proposals.map(p => [p.alias, p]));
|
|
138
|
+
const map = { __proto__: null };
|
|
139
|
+
for (const r of view.rows) { map[r.alias] = r.id; }
|
|
140
|
+
// #249 r2 C4: width is computed from the SANITIZED name -- a bidi/ANSI
|
|
141
|
+
// fragment stripped at render time must not skew the column alignment of
|
|
142
|
+
// every other row's padding.
|
|
143
|
+
const width = Math.max(6, ...view.rows.map(r => safeFragment(r.alias).length));
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (const g of groupAliases(map)) {
|
|
146
|
+
// F1 (#249 r2 review, C4 residual): for a vendor NOT in ALIAS_VENDOR_LABELS,
|
|
147
|
+
// `alias-groups.js :: vendorLabel` title-cases the raw vendor segment of a
|
|
148
|
+
// config VALUE (`aliasVendorOf`) rather than mapping it to house text --
|
|
149
|
+
// that is still third-party data, unlike the ~30 mapped labels. Sanitized
|
|
150
|
+
// HERE, not inside `vendorLabel`: that helper also renders into the
|
|
151
|
+
// Electron setup UI's HTML context (`electron/setup-ui-alias-groups.js`),
|
|
152
|
+
// out of scope for this terminal-only house rule.
|
|
153
|
+
lines.push(` ${safeFragment(g.label)}`);
|
|
154
|
+
for (const key of g.keys) {
|
|
155
|
+
const r = byAlias.get(key);
|
|
156
|
+
const p = proposalByAlias.get(key);
|
|
157
|
+
const flag = p ? rowFlag(p.reasons) : sameGatewayNote(r);
|
|
158
|
+
lines.push(` ${safeFragment(key).padEnd(width)} → ${safeFragment(r.id).padEnd(44)} ${r.state}${flag}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lines.push('');
|
|
162
|
+
// F1: an unavailable catalog cannot be checked for updates at all -- that is
|
|
163
|
+
// a different fact from "checked, nothing found" and must not print the
|
|
164
|
+
// reassuring "nothing to review" line.
|
|
165
|
+
if (!view.catalogAvailable) {
|
|
166
|
+
lines.push(' catalog unavailable — cannot check for updates (amicus models --refresh)');
|
|
167
|
+
return lines.join('\n') + '\n';
|
|
168
|
+
}
|
|
169
|
+
const n = view.proposals.length;
|
|
170
|
+
lines.push(n === 0
|
|
171
|
+
? ' nothing to review — amicus aliases --review'
|
|
172
|
+
: ` ${n} to review — amicus aliases --review`);
|
|
173
|
+
// F1: the catalog is available but stale -- name its age so a user who
|
|
174
|
+
// never runs --review still learns the background refresh isn't keeping up.
|
|
175
|
+
const fetchedAt = view.catalogInfo && view.catalogInfo.fetchedAt;
|
|
176
|
+
if (typeof fetchedAt === 'number' && (Date.now() - fetchedAt) > DEFAULT_MAX_AGE_MS) {
|
|
177
|
+
const days = Math.floor((Date.now() - fetchedAt) / DEFAULT_MAX_AGE_MS);
|
|
178
|
+
lines.push(` (catalog is ${days} day${days === 1 ? '' : 's'} old — amicus models --refresh)`);
|
|
179
|
+
}
|
|
180
|
+
return lines.join('\n') + '\n';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @returns {object} the `--json` document (fields only ever ADDED within SCHEMA_VERSION) */
|
|
184
|
+
function buildAliasesDoc(view) {
|
|
185
|
+
return {
|
|
186
|
+
schemaVersion: SCHEMA_VERSION,
|
|
187
|
+
type: 'aliases',
|
|
188
|
+
catalogAvailable: view.catalogAvailable,
|
|
189
|
+
catalogFetchedAt: view.catalogInfo.fetchedAt || null,
|
|
190
|
+
aliasCount: view.rows.length,
|
|
191
|
+
aliases: view.rows,
|
|
192
|
+
proposalCount: view.proposals.length,
|
|
193
|
+
proposals: view.proposals,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `amicus aliases --unpin <name>` (#238 F6, R1): "unpin" and "delete" are one
|
|
199
|
+
* operation -- remove the key -- whose meaning is decided by whether the
|
|
200
|
+
* name is curated (D1). The name is trimmed before every use -- for the
|
|
201
|
+
* `removeAlias` lookup, the `isCurated` check and both success messages --
|
|
202
|
+
* so a padded name neither crashes nor mis-reports which branch fired
|
|
203
|
+
* (#249 r1 R1). Blank/whitespace/literal-'null' names are refused here, and
|
|
204
|
+
* `removeAlias` is called under try/catch so any other throw (its own name
|
|
205
|
+
* guard included) becomes a clean exit 1, never an uncaught crash.
|
|
206
|
+
*
|
|
207
|
+
* R4 (#249 r2 D2): refused BEFORE any write when `name` is also
|
|
208
|
+
* `config.default` and NOT curated -- deleting it would leave the default
|
|
209
|
+
* dangling on a key that no longer resolves (`resolveModel` throws), and
|
|
210
|
+
* silently doing that fails the product principle (never a silent dangling
|
|
211
|
+
* default) as hard as a crash. Precedent: `cli-handlers-provider.js ::
|
|
212
|
+
* doRemove` re-points `config.default` when a provider goes away; here the
|
|
213
|
+
* user is deleting one alias on purpose, so refusing and naming the fix is
|
|
214
|
+
* the transparent choice instead. A CURATED default is unaffected -- it
|
|
215
|
+
* keeps resolving from the shipped table after the unpin, same as any other
|
|
216
|
+
* curated unpin. `config.default` may also be a bare model id rather than
|
|
217
|
+
* an alias name (`start-helpers.js` resolves either); the guard compares
|
|
218
|
+
* against the literal `name` argument, so a default that merely happens to
|
|
219
|
+
* RESOLVE to the same id as this alias is not what it's checking.
|
|
220
|
+
* @param {*} rawName whatever `args.unpin` parsed to
|
|
221
|
+
* @returns {number} exit code
|
|
222
|
+
*/
|
|
223
|
+
function handleUnpin(rawName) {
|
|
224
|
+
const name = typeof rawName === 'string' ? rawName.trim() : '';
|
|
225
|
+
if (!name || name === 'null') {
|
|
226
|
+
process.stderr.write('Error: --unpin requires an alias name\n');
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
const { removeAlias } = require('../utils/alias-store');
|
|
230
|
+
const { isCurated } = require('../utils/alias-state');
|
|
231
|
+
const config = require('../utils/config');
|
|
232
|
+
const defaults = config.getDefaultAliases();
|
|
233
|
+
const cfg = config.loadConfig();
|
|
234
|
+
if (cfg && cfg.default === name && !isCurated(name, defaults)) {
|
|
235
|
+
process.stderr.write(`Error: '${safeFragment(name)}' is your default model (config.default) — pick another default first (amicus setup)\n`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
let removed;
|
|
239
|
+
try {
|
|
240
|
+
removed = removeAlias(name);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
process.stderr.write(`Error: ${collapseExcerpt(err.message)}\n`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
if (!removed) {
|
|
246
|
+
process.stderr.write(`Error: '${safeFragment(name)}' is not pinned (see: amicus aliases)\n`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
process.stdout.write(isCurated(name, defaults)
|
|
250
|
+
? `✓ ${safeFragment(name)} now follows the shipped recommendation (${defaults[name]})\n`
|
|
251
|
+
: `✓ ${safeFragment(name)} removed\n`);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
|
|
256
|
+
async function handleAliases(args) {
|
|
257
|
+
if (args.review && (args.json || args.quiet)) {
|
|
258
|
+
process.stderr.write('Error: --review is interactive; use `amicus aliases --json` for machine output\n');
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
if (args.unpin !== undefined) {
|
|
262
|
+
if (args.review || args.json) {
|
|
263
|
+
process.stderr.write('Error: --unpin cannot be combined with --review or --json\n');
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
return handleUnpin(args.unpin); // handleUnpin trims/validates (R1): true, 42, '', ' ', 'null' all land the same error
|
|
267
|
+
}
|
|
268
|
+
if (args.review) { return require('./aliases-review').runReview(args); }
|
|
269
|
+
const d = loadDeps();
|
|
270
|
+
const view = await collectAliasView({ maxAgeMs: Number.POSITIVE_INFINITY }, d);
|
|
271
|
+
if (args.json) {
|
|
272
|
+
process.stdout.write(JSON.stringify(buildAliasesDoc(view), null, 2) + '\n');
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
process.stdout.write(renderAliasList(view, d.groupAliases));
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { handleAliases, collectAliasView, renderAliasList, buildAliasesDoc, loadDeps };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -46,6 +46,7 @@ const { deriveLegIds } = require('./leg-ids');
|
|
|
46
46
|
* to run this wave's legs on. Both or neither. When supplied this wave never
|
|
47
47
|
* starts a server and never closes one — see the seam comment in step 4.
|
|
48
48
|
* NOT `client`, which is the client TYPE string on this function.)
|
|
49
|
+
* serverAgents? (spec 2026-09-11 §4: agents to register if this wave starts its own server)
|
|
49
50
|
* @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
|
|
50
51
|
*/
|
|
51
52
|
async function runFanout(options) {
|
|
@@ -218,7 +219,12 @@ async function runFanout(options) {
|
|
|
218
219
|
logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
|
|
219
220
|
} else {
|
|
220
221
|
try {
|
|
221
|
-
({ client, server } = await startOpenCodeServer(mcpServers, {
|
|
222
|
+
({ client, server } = await startOpenCodeServer(mcpServers, {
|
|
223
|
+
models: validated.serverModels || okLegs.map(l => l.model),
|
|
224
|
+
// Spec 2026-09-11 §4: a wave that starts its own server still needs the
|
|
225
|
+
// council agents registered on it. Spread-guarded: byte-identical otherwise.
|
|
226
|
+
...(options.serverAgents ? { agents: options.serverAgents } : {}),
|
|
227
|
+
}));
|
|
222
228
|
} catch (err) {
|
|
223
229
|
writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
|
|
224
230
|
return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/sidecar/heartbeat.js
|
|
2
|
+
'use strict';
|
|
3
|
+
// HEARTBEAT_INTERVAL + createHeartbeat — moved verbatim from session-utils.js
|
|
4
|
+
// (size-gate split, spec 2026-09-11 §4 PR 2: that file was already at the
|
|
5
|
+
// 300-line ceiling before this task's additions). Zero behavior change; both
|
|
6
|
+
// re-exported from session-utils.js's existing module.exports, so no caller
|
|
7
|
+
// (start.js, resume.js, continue.js, fanout.js, index.js, and their tests)
|
|
8
|
+
// needs to change — see tests/sidecar/session-utils.test.js and
|
|
9
|
+
// tests/sidecar/start.test.js, which exercise these through that re-export.
|
|
10
|
+
|
|
11
|
+
/** Standard heartbeat interval in milliseconds */
|
|
12
|
+
const HEARTBEAT_INTERVAL = 15000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a heartbeat that writes status to stderr periodically.
|
|
16
|
+
* When sessionDir is provided, includes message count and latest activity.
|
|
17
|
+
*
|
|
18
|
+
* @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
|
|
19
|
+
* @param {string} [sessionDir] - Session directory to read progress from
|
|
20
|
+
* @returns {{ stop: () => void }}
|
|
21
|
+
*/
|
|
22
|
+
function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
const intervalId = setInterval(() => {
|
|
25
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
26
|
+
const mins = Math.floor(elapsed / 60);
|
|
27
|
+
const secs = elapsed % 60;
|
|
28
|
+
const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
|
|
29
|
+
|
|
30
|
+
if (sessionDir) {
|
|
31
|
+
const { readProgress } = require('./progress');
|
|
32
|
+
const progress = readProgress(sessionDir);
|
|
33
|
+
process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
|
|
34
|
+
} else {
|
|
35
|
+
process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
|
|
36
|
+
}
|
|
37
|
+
}, interval);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
stop() {
|
|
41
|
+
clearInterval(intervalId);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { HEARTBEAT_INTERVAL, createHeartbeat };
|