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
package/src/mcp-council-run.js
CHANGED
|
@@ -27,21 +27,11 @@ function textResult(text, isError) {
|
|
|
27
27
|
return result;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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-
|
|
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:
|
|
77
|
-
'
|
|
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:
|
|
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 |
|
|
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.
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
|
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
|
package/src/prompt-builder.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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 };
|