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.
@@ -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 };