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.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +2 -1
  4. package/bin/amicus.js +5 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/architecture-map.md +41 -6
  7. package/docs/configuration.md +14 -8
  8. package/docs/council.md +140 -3
  9. package/docs/usage.md +29 -6
  10. package/electron/ipc-setup.js +6 -9
  11. package/electron/setup-ui-alias-groups.js +29 -124
  12. package/package.json +1 -1
  13. package/schemas/council-verdict.schema.json +3 -1
  14. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  15. package/src/cli-council-run-tools.js +168 -0
  16. package/src/cli-handlers-council-run.js +6 -6
  17. package/src/cli-handlers.js +8 -1
  18. package/src/cli.js +34 -1
  19. package/src/council/briefings-chair.js +1 -1
  20. package/src/council/briefings-task.js +11 -5
  21. package/src/council/briefings.js +25 -7
  22. package/src/council/report-lost-rows.js +89 -0
  23. package/src/council/report-md.js +3 -1
  24. package/src/council/report.js +3 -2
  25. package/src/council/run-degrade.js +22 -1
  26. package/src/council/run-finish.js +23 -1
  27. package/src/council/run-launch.js +33 -4
  28. package/src/council/run-retry-launch.js +9 -4
  29. package/src/council/run-retry.js +3 -0
  30. package/src/council/run-seat-tools-verify.js +296 -0
  31. package/src/council/run-seat-tools.js +274 -0
  32. package/src/council/run-server.js +41 -6
  33. package/src/council/run-stage1-launch.js +8 -3
  34. package/src/council/run.js +21 -21
  35. package/src/council/seat-tools.js +299 -0
  36. package/src/council/verdict-seats-reviewed.js +76 -6
  37. package/src/headless.js +136 -6
  38. package/src/mcp-council-pack-map.js +24 -0
  39. package/src/mcp-council-run.js +17 -15
  40. package/src/mcp-server.js +2 -2
  41. package/src/mcp-tools.js +15 -4
  42. package/src/opencode-client.js +26 -0
  43. package/src/pack/pack-validate.js +3 -1
  44. package/src/prompt-builder.js +2 -2
  45. package/src/sidecar/aliases-review-gate.js +65 -0
  46. package/src/sidecar/aliases-review-prompt.js +91 -0
  47. package/src/sidecar/aliases-review-render.js +116 -0
  48. package/src/sidecar/aliases-review.js +298 -0
  49. package/src/sidecar/aliases.js +279 -0
  50. package/src/sidecar/fanout.js +7 -1
  51. package/src/sidecar/heartbeat.js +46 -0
  52. package/src/sidecar/models.js +20 -7
  53. package/src/sidecar/session-utils.js +7 -34
  54. package/src/sidecar/setup.js +20 -18
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/alias-groups.js +128 -0
  57. package/src/utils/alias-proposals.js +151 -0
  58. package/src/utils/alias-resolver.js +1 -1
  59. package/src/utils/alias-state.js +88 -0
  60. package/src/utils/alias-store.js +65 -0
  61. package/src/utils/config.js +10 -5
  62. package/src/utils/degrade.js +8 -0
  63. package/src/utils/model-id-siblings.js +106 -0
  64. package/src/utils/model-validator.js +1 -1
  65. package/src/utils/quick-picks.js +13 -32
  66. package/src/utils/text-sanitize.js +27 -0
@@ -27,21 +27,11 @@ function textResult(text, isError) {
27
27
  return result;
28
28
  }
29
29
 
30
- /**
31
- * v4.5 Task 15 (B7/F5): maps amicus_council_run's MCP input keys to the CLI
32
- * arg-key names applyPackToArgs's knob tables use (pack-resolve.js), so
33
- * applyPackToMcpInput can reuse those tables unchanged. `template` has no
34
- * Zod-declared counterpart on this tool (MCP has no template param of its
35
- * own — template/apply.js's own docblock: "MCP has no template params of its
36
- * own") — a pack's briefing.template is the ONLY way a template reaches this
37
- * handler, carried through as a plain (non-schema) `input.template` property
38
- * consumed by the render step below.
39
- */
40
- const COUNCIL_PACK_PARAM_MAP = {
41
- models: 'models', council: 'council', chair: 'chair', critic: 'critic', lenses: 'lenses',
42
- debate: 'debate', timeoutMinutes: 'timeout', maxCost: 'max-cost', gateway: 'gateway',
43
- template: 'template',
44
- };
30
+ // COUNCIL_PACK_PARAM_MAP lives in its own leaf (P2-R16, the 300-line size
31
+ // gate: this file was at 298 with no room for Task 6's tools/agent block) —
32
+ // re-exported below unchanged so tests/pack/mcp-pack-params.test.js's
33
+ // existing `require('../../src/mcp-council-run')` import keeps working.
34
+ const { COUNCIL_PACK_PARAM_MAP } = require('./mcp-council-pack-map');
45
35
 
46
36
  /**
47
37
  * amicus_council_run: validate → prep run dir → spawn CLI child → return
@@ -131,6 +121,14 @@ async function handleCouncilRunTool(input, project, helpers) {
131
121
  (typeof input.maxCost !== 'number' || !Number.isFinite(input.maxCost) || input.maxCost <= 0)) {
132
122
  return textResult('maxCost must be a positive number.', true);
133
123
  }
124
+ // Spec 2026-09-11 §4 (P2-R28 supersedes P2-R25): --tools/--agent are refused together, before either is consulted, on every door. Tools that never touch the tree (webfetch, websearch, todowrite) ride through; local tools are refused naming the CLI.
125
+ const toolsIn = (input.tools === undefined || input.tools === null) ? undefined : (Array.isArray(input.tools) ? input.tools : [String(input.tools)]);
126
+ const mcpWording = (m) => m.replace(/^--tools:/, 'tools:').replace(/--agent Build/g, 'agent: "Build"').replace(/(?<!run )--tools/g, 'tools').replace(/--agent/g, 'agent'); // C6 (P2-R35): rewrites flag wording EXCEPT inside an actual `council run --tools ...` CLI suggestion (resolveRemoteOnlyTools's local-tool message), which stays literal.
127
+ const conflict = require('./council/seat-tools').agentToolsConflict(input.agent, toolsIn);
128
+ if (conflict) { return textResult(mcpWording(conflict), true); }
129
+ const mt = (toolsIn !== undefined)
130
+ ? require('./council/seat-tools').resolveRemoteOnlyTools(toolsIn) : { ok: true, ids: [] };
131
+ if (!mt.ok) { return textResult(mcpWording(mt.message), true); }
134
132
 
135
133
  const { generateTaskId } = require('./sidecar/start');
136
134
  const runId = generateTaskId();
@@ -207,6 +205,10 @@ async function handleCouncilRunTool(input, project, helpers) {
207
205
  // v4.9 W5.2: emit-when-'task' — 'review' (the zod-declared default spelled
208
206
  // out) never reaches the child's argv; review-run argv stays byte-identical.
209
207
  if (input.intent === 'task') { args.push('--intent', 'task'); }
208
+ // Spec 2026-09-11 §4: remote tools + the Plan|Build override ride to the child as argv (the CLI door and runCouncil validate them again).
209
+ // Named mutant TOOLSEMITALWAYS: `mt.ids` (an array) is always truthy even when empty — swapping `.length` for a bare `mt.ids` check pushes `--tools ''` on every run; reddens 'absent tools/agent leave the argv byte-identical' in tests/mcp-council-run.test.js.
210
+ if (mt.ids.length) { args.push('--tools', mt.ids.join(',')); }
211
+ if (input.agent) { args.push('--agent', input.agent); }
210
212
 
211
213
  let child;
212
214
  try { child = helpers.spawnFn(args, runDir); } catch (err) {
package/src/mcp-server.js CHANGED
@@ -229,8 +229,8 @@ const HEADLESS_STATUS_REMINDER = '<system-reminder>Preferred: call amicus_wait w
229
229
  * v4.5 Task 15 (B7/F5): map amicus_fanout / amicus_start's MCP input keys to
230
230
  * the CLI arg-key names applyPackToArgs's knob tables use (pack-resolve.js),
231
231
  * so applyPackToMcpInput can reuse those tables unchanged — see
232
- * src/mcp-council-run.js's COUNCIL_PACK_PARAM_MAP for the sibling map and its
233
- * fuller docblock. `includeContext` is the one inverted-polarity knob: the
232
+ * src/mcp-council-pack-map.js's COUNCIL_PACK_PARAM_MAP for the sibling map and
233
+ * its fuller docblock. `includeContext` is the one inverted-polarity knob: the
234
234
  * pack/CLI side is `no-context` (true = drop context), the MCP side is
235
235
  * `includeContext` (true = keep context, default true).
236
236
  */
package/src/mcp-tools.js CHANGED
@@ -73,8 +73,8 @@ function getTools() {
73
73
  agent: z.enum(['Chat', 'Plan', 'Build']).optional()
74
74
  .describe(
75
75
  'Agent mode. Chat (interactive default; headless runs auto-convert ' +
76
- 'to Build): reads auto, writes ask permission. Plan: read-only ' +
77
- 'analysis. Build: full auto (all operations approved).'
76
+ 'to Build): reads auto, writes ask permission. Plan: analysis without ' +
77
+ 'edits (reads, searches and shell allowed). Build: full auto (all operations approved).'
78
78
  ),
79
79
  noUi: z.boolean().optional().describe(
80
80
  'Run headless without GUI. Default false (opens Electron window).'
@@ -345,7 +345,7 @@ function getTools() {
345
345
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
346
346
  ),
347
347
  agent: z.enum(['Plan', 'Build']).optional().describe(
348
- 'Agent mode for every leg. Build (default): full tool access. Plan: read-only analysis. Chat is not supported headless.'
348
+ 'Agent mode for every leg. Build (default): full tool access. Plan: analysis without edits (reads, searches and shell allowed). Chat is not supported headless.'
349
349
  ),
350
350
  thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional().describe(
351
351
  'Reasoning effort for every leg. Omitted: nothing is sent and each provider\'s own default effort governs. A leg whose model does not declare the level is refused before anything is spent; the other legs run. A leg whose model the engine\'s catalogue does not know in time is sent the level unverified.'
@@ -622,6 +622,17 @@ function getTools() {
622
622
  "run.json/verdict.json and kept out of the reliability ledger; 'review' is the " +
623
623
  'default and is never stored.'
624
624
  ),
625
+ tools: z.array(z.string().min(1)).max(12).optional().describe(
626
+ 'Tool ids stage-1 seats may use, by the engine\'s own ids (task mode defaults to webfetch, review to none). ' +
627
+ 'Over MCP only tools that never touch the tree (webfetch, websearch, todowrite) can be opted in: the MCP run ' +
628
+ 'directory stays inside the project, and a seat with local tools must not run there — use the CLI with ' +
629
+ '--out-dir outside the project for read/grep/glob/bash. task, skill, question, invalid, edit, write and ' +
630
+ 'apply_patch are always refused. Cannot be combined with agent.'
631
+ ),
632
+ agent: z.enum(['Plan', 'Build']).optional().describe(
633
+ 'Escape hatch: run every leg on the engine\'s own agent instead of the council agents (no tool allowlist). ' +
634
+ 'Cannot be combined with tools.'
635
+ ),
625
636
  ui: z.boolean().optional().describe(
626
637
  'Auto-open the Council Workspace window on this run. Default: opens when the client is ' +
627
638
  'Claude Code (local), Electron is installed, a display exists, and config workspace.autoOpen is ' +
@@ -742,7 +753,7 @@ Each leg is an ordinary session: read/resume/continue it by taskId.
742
753
  | Agent | Reads | Writes | Bash | Use When |
743
754
  |-------|-------|--------|------|----------|
744
755
  | Chat (interactive default*) | auto | asks | asks | Questions, analysis |
745
- | Plan | auto | denied | denied | Read-only analysis |
756
+ | Plan | auto | denied | auto | Analysis without edits |
746
757
  | Build | auto | auto | auto | Implementation tasks |
747
758
 
748
759
  * Headless (\`noUi\`) runs auto-convert Chat to Build — Chat would otherwise stall waiting on write/bash approval with no UI to approve it.
@@ -530,6 +530,7 @@ function resolveServerStartTimeoutMs(options = {}, env, platform) {
530
530
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
531
531
  * @param {number|null} [options.outputBudget] - #218 PR 3: the per-leg output budget startServer
532
532
  * already read; omitted means buildProviderModels reads config itself
533
+ * @param {Object<string, object>} [options.agents] - Extra agent configs to register (council seat agents, spec 2026-09-11 §4)
533
534
  * @returns {object} Server options ready for createOpencodeServer
534
535
  */
535
536
  function buildServerOptions(options = {}) {
@@ -655,6 +656,31 @@ function buildServerOptions(options = {}) {
655
656
  chat: chatAgent
656
657
  };
657
658
 
659
+ // Council seat agents (spec 2026-09-11 §4, PR 2): a per-run map of extra agents
660
+ // the caller already computed (council/seat-tools.js :: buildCouncilAgents).
661
+ // Merged AFTER `chat`, and a `chat` key is skipped outright (fix round 1 nit)
662
+ // so a caller genuinely can never displace the chat registration — without
663
+ // the skip, `agents: { chat: {...} }` would replace `config.agent.chat`
664
+ // with a brand-new object, detaching it from the `chatAgent` object the
665
+ // systemPrompt branch below still mutates by reference; seat-tools.js never
666
+ // emits a `chat` key, but the guard holds regardless of the caller. Absent,
667
+ // or not a plain object (arrays rejected too), this block is a no-op and
668
+ // every non-council server's config is byte-identical to today.
669
+ //
670
+ // council #247 round 6 (P2-R53): a council agent REPLACES any pre-existing
671
+ // same-name entry outright, rather than merging onto it — measured
672
+ // 2026-09-13 (probe-r6.js) that the old shallow merge below let a
673
+ // pre-existing entry's `prompt`/`temperature` survive and render on the
674
+ // seat. Named mutant AGENTMERGE: restoring the spread
675
+ // (`{ ...(config.agent[name] || {}), ...agentConfig }`) lets `prompt`
676
+ // survive again.
677
+ if (options.agents && typeof options.agents === 'object' && !Array.isArray(options.agents)) {
678
+ for (const [name, agentConfig] of Object.entries(options.agents)) {
679
+ if (name === 'chat') { continue; }
680
+ config.agent[name] = { ...agentConfig };
681
+ }
682
+ }
683
+
658
684
  // Set system prompt on the target agent's config (hidden from UI).
659
685
  // The promptAsync `system` field is rendered as a visible chat message,
660
686
  // but agent.prompt is injected as the system instruction invisibly.
@@ -16,7 +16,9 @@ const KINDS = ['council', 'fanout', 'solo'];
16
16
  /** Per-kind allowed `options` keys (spec §5.1; solo UI-suppression key per Task 0's verified flag set).
17
17
  * v4.5 HOLD-gate decision 2 (final-review F1): `agent`/`thinking`/`summaryLength`
18
18
  * are inert on EVERY council surface — handleCouncilRun never reads a pack-filled
19
- * one, and the engine hardcodes agent 'Plan'/summaryLength 'verbose' regardless.
19
+ * one; `--agent` (CLI) / the MCP `agent` param are a council run's only agent
20
+ * setters (spec 2026-09-11 §4, v4.9.8), and summaryLength stays hardcoded
21
+ * 'verbose' on every council launch regardless of what a pack sets.
20
22
  * Dropped from `council` pre-release rather than shipped as dead weight a pack
21
23
  * author would reasonably expect to work; a council pack that still sets one now
22
24
  * fails save/run validation (PACK_INVALID) like any other unknown option for the
@@ -234,7 +234,7 @@ ${context}`;
234
234
  * Note: Tool restrictions are now handled by OpenCode's native agent framework.
235
235
  * The agent parameter passed to OpenCode API controls permissions:
236
236
  * - Build: Full tool access (default)
237
- * - Plan: Read-only access
237
+ * - Plan: Edits denied; reads, searches and shell allowed (measured, opencode 1.18.15)
238
238
  * - Explore: Read-only subagent
239
239
  * - General: Full-access subagent
240
240
  *
@@ -258,7 +258,7 @@ Tool permissions are managed by the OpenCode agent framework based on your agent
258
258
  // buildPlanModeEnvironment) have been removed. OpenCode's native agent framework now handles
259
259
  // tool permissions based on the agent type:
260
260
  // - Build: Full tool access (default)
261
- // - Plan: Read-only access
261
+ // - Plan: Edits denied; reads, searches and shell allowed
262
262
  // - Explore: Read-only subagent
263
263
  // - General: Full-access subagent
264
264
  // See: https://opencode.ai/docs/agents/
@@ -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 };