amicus 4.9.8 → 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 +31 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +32 -4
- package/docs/architecture-map.md +22 -2
- package/docs/configuration.md +14 -8
- package/docs/usage.md +22 -3
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- package/package.json +1 -1
- package/src/cli-handlers.js +8 -1
- package/src/cli.js +11 -0
- 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/models.js +20 -7
- package/src/sidecar/setup.js +20 -18
- 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/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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/aliases-review-gate
|
|
3
|
+
* Pure §5-gate helpers for `amicus aliases --review` (#249 r1 R2/R3), split
|
|
4
|
+
* into their own module rather than grown onto aliases-review-render.js (it
|
|
5
|
+
* was already at five exports) or aliases-review.js (it was already at the
|
|
6
|
+
* 300-line wall). Every export here takes plain data and returns a string or
|
|
7
|
+
* a classification — no I/O, no config reads/writes, no `ask`.
|
|
8
|
+
*
|
|
9
|
+
* #249 r2 C4: a typed id is user input rendered straight to a terminal, so
|
|
10
|
+
* both line-builders below quote it through `safeFragment` (the house
|
|
11
|
+
* sanitizer, `utils/text-sanitize.js`) — the fragment, not the composed
|
|
12
|
+
* line, per `alias-shadow.js :: formatAliasShadow`'s rule.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { ageLabel } = require('./aliases-review-render');
|
|
18
|
+
const { safeFragment } = require('../utils/text-sanitize');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Classifies a typed "choose another" model id against the §5 display gate
|
|
22
|
+
* (R2): a bare/unknown id is a different failure from a real catalog row the
|
|
23
|
+
* engine would never have offered — `alias-proposals.js :: gatedCatalogIds`
|
|
24
|
+
* is the same rule the numbered menu's own candidates are built from, so a
|
|
25
|
+
* typed id can never bypass it.
|
|
26
|
+
* @param {string} id
|
|
27
|
+
* @param {Set<string>} allCatalogIds every id the raw catalog carries
|
|
28
|
+
* @param {Set<string>} gatedIds ids the §5 display gate allows as a candidate
|
|
29
|
+
* @returns {'unknown'|'ungated'|'ok'}
|
|
30
|
+
*/
|
|
31
|
+
function classifyTypedId(id, allCatalogIds, gatedIds) {
|
|
32
|
+
if (!id.includes('/') || !allCatalogIds.has(id)) { return 'unknown'; }
|
|
33
|
+
if (!gatedIds.has(id)) { return 'ungated'; }
|
|
34
|
+
return 'ok';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @returns {string} the line for an id absent from the catalog entirely */
|
|
38
|
+
function notInCatalogLine(id) {
|
|
39
|
+
return ` not in the catalog — try: amicus models --search ${safeFragment(id).split('/').pop()}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {string} the line for an id present in the catalog but excluded by the §5 display gate */
|
|
43
|
+
function notVerifiedLine(id) {
|
|
44
|
+
return ` ${safeFragment(id)} is in the catalog but was not verified this run (floor row or rejected provider) — refresh and try again\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The §5 write-gate banner for a not-fresh catalog (R3). Never called with a
|
|
49
|
+
* fresh one — callers gate on `isFresh` first. A future `fetchedAt` (clock
|
|
50
|
+
* skew) gets its own line instead of a negative age reaching `ageLabel`.
|
|
51
|
+
* @param {number|null} fetchedAt
|
|
52
|
+
* @param {number} now
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function staleCatalogBanner(fetchedAt, now) {
|
|
56
|
+
if (typeof fetchedAt === 'number' && fetchedAt > now) {
|
|
57
|
+
return ' catalog timestamp is in the future (clock skew?) — proposals are shown, but accepting is disabled until `amicus models --refresh` succeeds\n';
|
|
58
|
+
}
|
|
59
|
+
if (typeof fetchedAt === 'number') {
|
|
60
|
+
return ` catalog is ${ageLabel(fetchedAt, now)} old and could not be refreshed — proposals are shown, but accepting is disabled until \`amicus models --refresh\` succeeds\n`;
|
|
61
|
+
}
|
|
62
|
+
return ' no catalog cache and it could not be fetched — proposals are shown, but accepting is disabled until `amicus models --refresh` succeeds\n';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { classifyTypedId, notInCatalogLine, notVerifiedLine, staleCatalogBanner };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/aliases-review-prompt
|
|
3
|
+
* The real-readline prompt for `amicus aliases --review`, split out of
|
|
4
|
+
* aliases-review.js (#249 r2 D1) once that file hit the 300-line wall —
|
|
5
|
+
* the same reason aliases-review-render.js and aliases-review-gate.js were
|
|
6
|
+
* split out before it.
|
|
7
|
+
*
|
|
8
|
+
* Ctrl-C and Ctrl-D/EOF, MEASURED (Node 24, `terminal: true` over a
|
|
9
|
+
* `PassThrough`, `input.write('\x03')`/`'\x04'`, `input.end()`): the two
|
|
10
|
+
* keystrokes are NOT the same event. Ctrl-D/EOF closes the input stream,
|
|
11
|
+
* which readline surfaces as its own `'close'` event. Ctrl-C in a raw-mode
|
|
12
|
+
* terminal is readline's own `'SIGINT'` event — NOT the process `SIGINT`
|
|
13
|
+
* signal — and with no listener attached, readline's default action is to
|
|
14
|
+
* pause and then close the interface itself, which is why the pre-existing
|
|
15
|
+
* `'close'` handler already covered Ctrl-C even before this split (the r2
|
|
16
|
+
* D1 finding's MECHANISM claim — "Ctrl-C is dead" — was refuted). What
|
|
17
|
+
* had no real-trigger test was that this depended on an inherited default:
|
|
18
|
+
* the previous M1 test (aliases-review.test.js) injects an `ask` that
|
|
19
|
+
* throws, and never drives an actual keystroke through readline. Attaching
|
|
20
|
+
* `rl.on('SIGINT', () => rl.close())` below makes the abort path OURS —
|
|
21
|
+
* explicit, and still correct if anything else ever attaches its own
|
|
22
|
+
* `'SIGINT'` listener to this interface, which would otherwise suppress
|
|
23
|
+
* readline's default close-on-SIGINT behaviour.
|
|
24
|
+
*
|
|
25
|
+
* F3 (#249 r2 review): a Ctrl-C with no `ask` PENDING — e.g. during the
|
|
26
|
+
* caller's inline catalog refresh, which runs after `createPrompt()` and
|
|
27
|
+
* before the first `ask()` — closes `rl` with nothing to reject; the next
|
|
28
|
+
* `ask()` then calls `rl.question` on an already-closed interface, which
|
|
29
|
+
* throws `ERR_USE_AFTER_CLOSE` instead of ever reaching `'close'`'s reject.
|
|
30
|
+
* `ask` tracks that window itself (a plain closure flag, not the
|
|
31
|
+
* undocumented `rl.closed`) and short-circuits to the SAME `REVIEW_ABORTED`
|
|
32
|
+
* error the pending-ask path builds.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
/** @returns {Error} the one `REVIEW_ABORTED` shape both abort paths in `createPrompt` build. */
|
|
38
|
+
function abortedError() {
|
|
39
|
+
const err = new Error('aliases --review interrupted');
|
|
40
|
+
err.code = 'REVIEW_ABORTED';
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, terminal?: boolean}} [opts]
|
|
46
|
+
* `terminal` is passed through to `readline.createInterface` only when it
|
|
47
|
+
* is a boolean; omitted, readline picks its own default (`output.isTTY`).
|
|
48
|
+
* @returns {{ask: (q: string) => Promise<string>, close: () => void}}
|
|
49
|
+
* `ask` resolves the trimmed answer; on an aborted prompt (Ctrl-C,
|
|
50
|
+
* Ctrl-D/EOF, or the input stream ending) it rejects with
|
|
51
|
+
* `Error('aliases --review interrupted')`, `code: 'REVIEW_ABORTED'`.
|
|
52
|
+
* `close` closes the interface; safe to call with no pending `ask` and
|
|
53
|
+
* safe to call more than once (readline's own `close` is idempotent).
|
|
54
|
+
*/
|
|
55
|
+
function createPrompt(opts = {}) {
|
|
56
|
+
const { input = process.stdin, output = process.stdout, terminal } = opts;
|
|
57
|
+
const readline = require('readline');
|
|
58
|
+
const rl = readline.createInterface(
|
|
59
|
+
typeof terminal === 'boolean' ? { input, output, terminal } : { input, output }
|
|
60
|
+
);
|
|
61
|
+
let pendingReject = null;
|
|
62
|
+
let closed = false; // F3: ours, not the undocumented rl.closed -- see module docblock
|
|
63
|
+
// Ctrl-D/EOF (or the stream simply ending) closes stdin without ever
|
|
64
|
+
// invoking the `question` callback -- reject any in-flight `ask` so the
|
|
65
|
+
// caller's loop ends with a summary instead of hanging or exiting silently.
|
|
66
|
+
rl.on('close', () => {
|
|
67
|
+
closed = true;
|
|
68
|
+
if (pendingReject) {
|
|
69
|
+
const reject = pendingReject;
|
|
70
|
+
pendingReject = null;
|
|
71
|
+
reject(abortedError());
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// See the module docblock: this is readline's own 'SIGINT' event (a
|
|
75
|
+
// raw-mode-terminal Ctrl-C), not the process signal. Closing here makes
|
|
76
|
+
// the abort path explicit rather than relying on readline's inherited
|
|
77
|
+
// default, which only fires when nothing else has claimed this event.
|
|
78
|
+
rl.on('SIGINT', () => rl.close());
|
|
79
|
+
const ask = (q) => new Promise((resolve, reject) => {
|
|
80
|
+
// F3: already closed with no pending ask (see module docblock) -- calling
|
|
81
|
+
// rl.question here would throw ERR_USE_AFTER_CLOSE instead of ever
|
|
82
|
+
// reaching the 'close' handler's reject above.
|
|
83
|
+
if (closed) { reject(abortedError()); return; }
|
|
84
|
+
pendingReject = reject;
|
|
85
|
+
rl.question(q, (a) => { pendingReject = null; resolve((a || '').trim()); });
|
|
86
|
+
});
|
|
87
|
+
const close = () => rl.close();
|
|
88
|
+
return { ask, close };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { createPrompt };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/aliases-review-render
|
|
3
|
+
* Pure, side-effect-free screen text for `amicus aliases --review` (#238 §4),
|
|
4
|
+
* split out of aliases-review.js (fix round 1) to keep that file under the
|
|
5
|
+
* 300-line gate once the round's robustness fixes landed. Every export here
|
|
6
|
+
* takes plain data and returns a string — no I/O, no config reads/writes, no
|
|
7
|
+
* `ask`. `reasonPhrase` is an internal helper (only `renderScreen` calls it)
|
|
8
|
+
* and is deliberately not exported, keeping this module's surface small.
|
|
9
|
+
*
|
|
10
|
+
* #249 r2 C4: every alias name, model id and catalog-sourced note this
|
|
11
|
+
* module interpolates onto the screen rides `safeFragment` (the house
|
|
12
|
+
* sanitizer, `utils/text-sanitize.js`) FIRST — the fragment, never the
|
|
13
|
+
* composed line, per `alias-shadow.js :: formatAliasShadow`'s rule.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { DEFAULT_MAX_AGE_MS } = require('../utils/model-catalog');
|
|
19
|
+
const { safeFragment } = require('../utils/text-sanitize');
|
|
20
|
+
|
|
21
|
+
const LABEL_WIDTH = 11; // 'currently' / 'shipped' / 'proposed', each padded flush with the others
|
|
22
|
+
|
|
23
|
+
/** @returns {string} the reason phrase shown on the 'proposed' line for one candidate */
|
|
24
|
+
function reasonPhrase(c) {
|
|
25
|
+
if (c.why === 'newer-sibling') { return 'newer sibling, same tier'; }
|
|
26
|
+
if (c.why === 'replacement') { return 'replacement (current id is gone from the catalog)'; }
|
|
27
|
+
if (c.why === 'follow') { return 'the shipped recommendation'; }
|
|
28
|
+
return safeFragment(c.evidence && c.evidence.note) || 'notable model';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {number} fetchedAt must be a number (callers gate on `isFresh`/
|
|
33
|
+
* `typeof` first — a missing cache gets its own banner, never this)
|
|
34
|
+
* @param {number} now
|
|
35
|
+
* @returns {string} e.g. '3 days' / '1 day' / '5 hours' / '1 hour'
|
|
36
|
+
*/
|
|
37
|
+
function ageLabel(fetchedAt, now) {
|
|
38
|
+
const ms = now - fetchedAt;
|
|
39
|
+
const days = Math.floor(ms / DEFAULT_MAX_AGE_MS);
|
|
40
|
+
if (days >= 1) { return `${days} day${days === 1 ? '' : 's'}`; }
|
|
41
|
+
const hours = Math.max(1, Math.floor(ms / 3600000));
|
|
42
|
+
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Minor (spec §4): "refreshing catalog (3 days old)…" -- printed BEFORE the
|
|
47
|
+
* picker's own inline refresh, from the last cache on disk, so a stale-cache
|
|
48
|
+
* wait reads as progress rather than a hang. Pure: takes the pre-refresh
|
|
49
|
+
* `readCache()` doc and `now`, returns the line or null.
|
|
50
|
+
* @param {{fetchedAt?: number}|null} cache
|
|
51
|
+
* @param {number} now
|
|
52
|
+
* @returns {string|null} the line (with trailing newline), or null when the
|
|
53
|
+
* cache is missing or not old enough to be worth naming
|
|
54
|
+
*/
|
|
55
|
+
function refreshingCatalogLine(cache, now) {
|
|
56
|
+
if (!cache || typeof cache.fetchedAt !== 'number' || (now - cache.fetchedAt) <= DEFAULT_MAX_AGE_MS) { return null; }
|
|
57
|
+
return ` refreshing catalog (${ageLabel(cache.fetchedAt, now)} old)…\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} p one proposal
|
|
62
|
+
* @returns {Array<{label: string, action: 'accept'|'choose'|'skip'|'dismiss', candidate?: object}>}
|
|
63
|
+
* one entry per candidate in the engine's order (never re-sorted), then the
|
|
64
|
+
* three standing options
|
|
65
|
+
*/
|
|
66
|
+
function menuFor(p) {
|
|
67
|
+
const alias = safeFragment(p.alias);
|
|
68
|
+
const items = p.candidates.map(c => {
|
|
69
|
+
const id = safeFragment(c.id);
|
|
70
|
+
return {
|
|
71
|
+
label: c.why === 'follow' ? `follow the shipped pin (${id})` : (c.why === 'notable' ? `add ${alias} → ${id}` : `accept ${id}`),
|
|
72
|
+
action: 'accept',
|
|
73
|
+
candidate: c,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
items.push({ label: 'choose another', action: 'choose' });
|
|
77
|
+
items.push({ label: 'skip', action: 'skip' });
|
|
78
|
+
items.push({ label: 'never ask again', action: 'dismiss' });
|
|
79
|
+
return items;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {Array} items from `menuFor` @returns {string} the numbered menu line alone, no trailing newline */
|
|
83
|
+
function menuLineText(items) {
|
|
84
|
+
return ' ' + items.map((it, idx) => `[${idx + 1}] ${it.label}`).join(' ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {object} p one proposal
|
|
89
|
+
* @param {number} i zero-based index
|
|
90
|
+
* @param {number} n total proposal count
|
|
91
|
+
* @param {Array} items from `menuFor`
|
|
92
|
+
* @returns {string} the full screen for one proposal — header, state block and menu — trailing newline included
|
|
93
|
+
*/
|
|
94
|
+
function renderScreen(p, i, n, items) {
|
|
95
|
+
const lines = [` [${i + 1}/${n}] ${safeFragment(p.alias)}`];
|
|
96
|
+
lines.push(p.current
|
|
97
|
+
? ` ${'currently'.padEnd(LABEL_WIDTH)}${safeFragment(p.current)} (pinned)`
|
|
98
|
+
: ' not mapped yet');
|
|
99
|
+
if (p.curated) { lines.push(` ${'shipped'.padEnd(LABEL_WIDTH)}${safeFragment(p.shipped)}`); }
|
|
100
|
+
const top = p.candidates[0];
|
|
101
|
+
if (top) {
|
|
102
|
+
lines.push(` ${'proposed'.padEnd(LABEL_WIDTH)}${safeFragment(top.id)} ${reasonPhrase(top)}`);
|
|
103
|
+
} else if ((p.reasons || []).includes('stale')) {
|
|
104
|
+
// F3: a stale pin with no same-vendor replacement and no sibling still
|
|
105
|
+
// has something to say -- silently showing no reason at all read as the
|
|
106
|
+
// engine finding nothing wrong, when what happened is the opposite.
|
|
107
|
+
lines.push(` ${'stale'.padEnd(LABEL_WIDTH)}current id is gone from the catalog — no same-vendor replacement found`);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(` ${'reason'.padEnd(LABEL_WIDTH)}${(p.reasons || []).join(', ')}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push(menuLineText(items));
|
|
113
|
+
return lines.join('\n') + '\n';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { ageLabel, menuFor, menuLineText, renderScreen, refreshingCatalogLine };
|
|
@@ -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 };
|