amicus 2.0.0 → 2.2.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/src/mcp-server.js CHANGED
@@ -205,13 +205,13 @@ function appendVersionWarning(content) {
205
205
  */
206
206
  function computeNextPoll() {
207
207
  return {
208
- hint: 'Run `sleep 25` in your shell before calling amicus_status again. This enforces the wait and prevents token-wasting rapid polls. Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true.',
208
+ hint: 'Preferred: call amicus_wait with this task ID — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true. Fallback (no amicus_wait tool available): run `sleep 25` in your shell before calling amicus_status again. This enforces the wait and prevents token-wasting rapid polls.',
209
209
  wait_command: 'sleep 25',
210
210
  };
211
211
  }
212
212
 
213
- const HEADLESS_START_REMINDER = '<system-reminder>IMPORTANT: Before calling amicus_status, you MUST run `sleep 25` in your shell first. This enforces the polling interval and prevents token waste. Do other useful work while waiting, or run `sleep 25` to block until the next poll window. Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true.</system-reminder>';
214
- const HEADLESS_STATUS_REMINDER = '<system-reminder>IMPORTANT: This session is still running. Before calling amicus_status again, you MUST run `sleep 25` in your shell first. Each premature poll wastes context tokens for zero benefit. Run `sleep 25` now, then check again. Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true.</system-reminder>';
213
+ const HEADLESS_START_REMINDER = '<system-reminder>Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true. Fallback (no amicus_wait tool available): before calling amicus_status, you MUST run `sleep 25` in your shell first. This enforces the polling interval and prevents token waste. Do other useful work while waiting, or run `sleep 25` to block until the next poll window.</system-reminder>';
214
+ const HEADLESS_STATUS_REMINDER = '<system-reminder>Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true. Fallback (no amicus_wait tool available): this session is still running. Before calling amicus_status again, you MUST run `sleep 25` in your shell first. Each premature poll wastes context tokens for zero benefit. Run `sleep 25` now, then check again.</system-reminder>';
215
215
 
216
216
  /** Spawn an Amicus CLI process (fire-and-forget) */
217
217
  function spawnSidecarProcess(args, sessionDir) {
@@ -672,7 +672,8 @@ const handlers = {
672
672
  const legsTotal = (readMeta.legs || []).length;
673
673
  const stillRunning = !readMeta.status || readMeta.status === 'running';
674
674
  const msg = stillRunning
675
- ? `Wave ${input.taskId} is still running (${legsTotal} legs). Poll amicus_status.`
675
+ ? `Wave ${input.taskId} is still running (${legsTotal} legs). Preferred: call amicus_wait ` +
676
+ 'with this waveId — one blocking call replaces polling. Fallback: poll amicus_status.'
676
677
  : `Wave ${input.taskId} ended with status '${readMeta.status}' before writing wave.json ` +
677
678
  '(fan-out may have been killed). Read individual legs by taskId, or use mode \'metadata\'.';
678
679
  return textResult(msg);
@@ -976,7 +977,9 @@ const handlers = {
976
977
 
977
978
  const body = JSON.stringify({
978
979
  waveId, taskIds: legIds, status: 'running', mode: 'headless',
979
- message: 'Fan-out started. Poll amicus_status with the waveId; amicus_read the waveId when complete.',
980
+ message: 'Fan-out started. Preferred: call amicus_wait with the waveId one blocking call ' +
981
+ 'replaces polling; re-call it while it returns timedOut: true. Fallback: poll amicus_status ' +
982
+ 'with the waveId. Either way, amicus_read the waveId when complete.',
980
983
  });
981
984
  return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
982
985
  },
package/src/mcp-tools.js CHANGED
@@ -44,8 +44,11 @@ function getTools() {
44
44
  'ALWAYS use HEADLESS (noUi: true) for all of them unless the user ' +
45
45
  'explicitly requests interactive. Opening multiple Electron windows ' +
46
46
  'at once is disruptive. ' +
47
- 'For headless mode, ALWAYS run `sleep 25` in your shell before each ' +
48
- 'amicus_status call to enforce the polling interval. ' +
47
+ 'For headless mode, prefer calling amicus_wait with the task ID one ' +
48
+ 'blocking call replaces the sleep+status loop; re-call it while it returns ' +
49
+ 'timedOut: true. Fallback (no amicus_wait tool available): ALWAYS run ' +
50
+ '`sleep 25` in your shell before each amicus_status call to enforce the ' +
51
+ 'polling interval. ' +
49
52
  'For interactive mode, do not poll. Wait for the user to tell you ' +
50
53
  'they\'ve clicked Fold, then use amicus_read. ' +
51
54
  'Call amicus_guide first if you need help choosing a model or writing a good briefing.' +
@@ -61,9 +64,9 @@ function getTools() {
61
64
  ),
62
65
  agent: z.enum(['Chat', 'Plan', 'Build']).optional()
63
66
  .default('Chat').describe(
64
- 'Agent mode. Chat (default): reads auto, writes ask ' +
65
- 'permission. Plan: read-only analysis. Build: full auto ' +
66
- '(all operations approved).'
67
+ 'Agent mode. Chat (interactive default; headless runs auto-convert ' +
68
+ 'to Build): reads auto, writes ask permission. Plan: read-only ' +
69
+ 'analysis. Build: full auto (all operations approved).'
67
70
  ),
68
71
  noUi: z.boolean().optional().default(false).describe(
69
72
  'Run headless without GUI. Default false (opens Electron window).'
@@ -215,7 +218,8 @@ function getTools() {
215
218
  description:
216
219
  'Reopen a previous Amicus session with full conversation history ' +
217
220
  'preserved. The session continues in the same OpenCode session. ' +
218
- 'Returns a task ID immediately — use amicus_status to poll.',
221
+ 'Returns a task ID immediately — use amicus_wait to block until done ' +
222
+ '(or poll amicus_status).',
219
223
  inputSchema: {
220
224
  taskId: safeTaskId.describe(
221
225
  'The task ID of the session to resume.'
@@ -238,7 +242,7 @@ function getTools() {
238
242
  'Start a new Amicus session that inherits a previous session\'s ' +
239
243
  'conversation as context. The previous session\'s messages become ' +
240
244
  'read-only background for the new task. Returns a task ID ' +
241
- 'immediately — use amicus_status to poll.',
245
+ 'immediately — use amicus_wait to block until done (or poll amicus_status).',
242
246
  inputSchema: {
243
247
  taskId: safeTaskId.describe(
244
248
  'The task ID of the previous session to continue from.'
@@ -296,10 +300,12 @@ function getTools() {
296
300
  description:
297
301
  'Run N models on the SAME prompt in parallel (one shared engine) and ' +
298
302
  'aggregate the results. Headless only. Returns {waveId, taskIds[]} ' +
299
- 'immediately. Poll amicus_status with the waveId (run `sleep 25` between ' +
300
- 'polls); when done, amicus_read the waveId for the aggregated JSON wave ' +
301
- 'document (per-leg summaries inside). Each leg is also an ordinary ' +
302
- 'session readable by taskId.',
303
+ 'immediately. Preferred: call amicus_wait with the waveId one blocking ' +
304
+ 'call replaces polling; re-call it while it returns timedOut: true. ' +
305
+ 'Fallback (no amicus_wait tool available): poll amicus_status with the ' +
306
+ 'waveId (run `sleep 25` between polls). Either way, amicus_read the waveId ' +
307
+ 'when done for the aggregated JSON wave document (per-leg summaries ' +
308
+ 'inside). Each leg is also an ordinary session readable by taskId.',
303
309
  inputSchema: {
304
310
  models: z.array(safeModel).min(1).max(10).optional().describe(
305
311
  `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
@@ -442,16 +448,18 @@ Amicus spawns parallel conversations with different LLMs and folds results back
442
448
 
443
449
  ### Headless Mode (noUi: true)
444
450
  1. amicus_start with model + prompt + noUi: true -> get task ID
445
- 2. Run \`sleep 25\` in your shell (this enforces the polling interval)
446
- 3. amicus_status to check progress
447
- 4. If still running, run \`sleep 25\` again before each subsequent amicus_status call
448
- 5. amicus_read to get the summary once complete
449
- 6. Act on findings
451
+ 2. **Preferred:** call amicus_wait with the task ID one blocking call (up to
452
+ ~50s) replaces the sleep+status loop; re-call it while it returns timedOut: true
453
+ 3. amicus_read to get the summary once complete
454
+ 4. Act on findings
450
455
 
451
- (Alternative to steps 2-4: call amicus_wait with the task ID — one call blocks
452
- up to ~50s and returns status; call it again while it returns timedOut: true.)
456
+ **Fallback (only if amicus_wait is unavailable):**
457
+ 1. Run \`sleep 25\` in your shell (this enforces the polling interval)
458
+ 2. amicus_status to check progress
459
+ 3. If still running, run \`sleep 25\` again before each subsequent amicus_status call
460
+ 4. amicus_read to get the summary once complete
453
461
 
454
- **IMPORTANT:** Always run \`sleep 25\` before every amicus_status call. This is not optional. Each premature poll wastes context tokens for zero benefit. The sleep command enforces the wait mechanically.
462
+ **IMPORTANT (fallback path only):** Always run \`sleep 25\` before every amicus_status call. This is not optional. Each premature poll wastes context tokens for zero benefit. The sleep command enforces the wait mechanically.
455
463
 
456
464
  ### Interactive Mode (noUi: false, default)
457
465
  1. amicus_start with model + prompt -> get task ID
@@ -463,17 +471,19 @@ up to ~50s and returns status; call it again while it returns timedOut: true.)
463
471
  ### Fan-Out (amicus_fanout)
464
472
  Run the SAME prompt across 1-10 models in parallel (one shared engine):
465
473
  1. amicus_fanout with models + prompt -> {waveId, taskIds[]}
466
- 2. sleep 25, then amicus_status with the waveId (repeat until done), or call amicus_wait with the waveId
474
+ 2. **Preferred:** call amicus_wait with the waveId (re-call while timedOut: true). **Fallback:** sleep 25, then amicus_status with the waveId (repeat until done)
467
475
  3. amicus_read the waveId -> aggregated JSON wave document (per-leg summaries inside)
468
476
  Each leg is an ordinary session: read/resume/continue it by taskId.
469
477
 
470
478
  ## Agent Selection
471
479
  | Agent | Reads | Writes | Bash | Use When |
472
480
  |-------|-------|--------|------|----------|
473
- | Chat (default) | auto | asks | asks | Questions, analysis |
481
+ | Chat (interactive default*) | auto | asks | asks | Questions, analysis |
474
482
  | Plan | auto | denied | denied | Read-only analysis |
475
483
  | Build | auto | auto | auto | Implementation tasks |
476
484
 
485
+ * Headless (\`noUi\`) runs auto-convert Chat to Build — Chat would otherwise stall waiting on write/bash approval with no UI to approve it.
486
+
477
487
  ## Writing Good Briefings
478
488
  Include: Objective, Background, Files of interest, Success criteria, Constraints.
479
489
 
@@ -123,7 +123,7 @@ async function continueSidecar(options) {
123
123
  headless = false,
124
124
  timeout = 15,
125
125
  agent,
126
- mcp, mcpConfig, client, noMcp, excludeMcp
126
+ mcp, mcpConfig, client, noMcp, excludeMcp, json = false
127
127
  } = options;
128
128
 
129
129
  // Load previous session data
@@ -185,10 +185,17 @@ async function continueSidecar(options) {
185
185
 
186
186
  try {
187
187
  if (headless) {
188
- result = await runHeadless(
189
- model, systemPrompt, userMessage, newTaskId, project,
190
- timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
191
- );
188
+ try {
189
+ result = await runHeadless(
190
+ model, systemPrompt, userMessage, newTaskId, project,
191
+ timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
192
+ );
193
+ } catch (err) {
194
+ if (!json) { throw err; }
195
+ // --json contract: stdout must always carry a parseable run doc,
196
+ // even when the engine throws rather than returning {error}.
197
+ result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: newTaskId };
198
+ }
192
199
  summary = result.summary ||
193
200
  '## Sidecar Results: No Output\n\nContinued session completed without summary.';
194
201
 
@@ -209,8 +216,8 @@ async function continueSidecar(options) {
209
216
  releaseLock(prevSessionDir);
210
217
  }
211
218
 
212
- // Output summary
213
- outputSummary(summary);
219
+ // Output summary (human mode only — json mode keeps stdout to the doc below)
220
+ if (!json) { outputSummary(summary); }
214
221
 
215
222
  // Load current metadata for finalization
216
223
  const metaPath = SessionPaths.metadataFile(sessionDir);
@@ -230,8 +237,16 @@ async function continueSidecar(options) {
230
237
  writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
231
238
  logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
232
239
  } else {
233
- finalizeSession(sessionDir, summary, project, meta, { status: terminal.status });
240
+ finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status });
234
241
  }
242
+
243
+ if (json) {
244
+ const { buildRunResult } = require('../utils/result-schema');
245
+ const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
246
+ const doc = buildRunResult({ taskId: newTaskId, metadata: finalMeta, result, summary, sessionDir });
247
+ console.log(JSON.stringify(doc, null, 2));
248
+ }
249
+
235
250
  return terminal.exitCode;
236
251
  }
237
252
 
@@ -119,7 +119,7 @@ function updateSessionStatus(sessionDir, status) {
119
119
  async function resumeSidecar(options) {
120
120
  const {
121
121
  taskId, project = process.cwd(), headless = false, timeout = 15,
122
- mcp, mcpConfig, client, noMcp, excludeMcp
122
+ mcp, mcpConfig, client, noMcp, excludeMcp, json = false
123
123
  } = options;
124
124
 
125
125
  // Resume operates on an EXISTING session — resolve dual-dir (amicus, then legacy).
@@ -191,10 +191,17 @@ async function resumeSidecar(options) {
191
191
 
192
192
  if (headless) {
193
193
  const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
194
- result = await runHeadless(
195
- metadata.model, resumePrompt, userMessage,
196
- taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
197
- );
194
+ try {
195
+ result = await runHeadless(
196
+ metadata.model, resumePrompt, userMessage,
197
+ taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
198
+ );
199
+ } catch (err) {
200
+ if (!json) { throw err; }
201
+ // --json contract: stdout must always carry a parseable run doc,
202
+ // even when the engine throws rather than returning {error}.
203
+ result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId };
204
+ }
198
205
  summary = result.summary || '## Sidecar Results: No Output\n\nResumed session completed without summary.';
199
206
 
200
207
  if (result.timedOut) { logger.warn('Resume task timed out', { taskId }); }
@@ -218,8 +225,8 @@ async function resumeSidecar(options) {
218
225
  if (result.error) { logger.error('Interactive resume error', { taskId, error: result.error }); }
219
226
  }
220
227
 
221
- // Output summary
222
- outputSummary(summary);
228
+ // Output summary (human mode only — json mode keeps stdout to the doc below)
229
+ if (!json) { outputSummary(summary); }
223
230
 
224
231
  // Map the run result to the canonical terminal status + exit code —
225
232
  // mirrors start.js. Explicit status preserves the interactive
@@ -234,8 +241,16 @@ async function resumeSidecar(options) {
234
241
  writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
235
242
  logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
236
243
  } else {
237
- finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
244
+ finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status });
238
245
  }
246
+
247
+ if (json) {
248
+ const { buildRunResult } = require('../utils/result-schema');
249
+ const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
250
+ const doc = buildRunResult({ taskId, metadata: finalMeta, result, summary, sessionDir });
251
+ console.log(JSON.stringify(doc, null, 2));
252
+ }
253
+
239
254
  return terminal.exitCode; // finally below still releases the lock first
240
255
  } finally {
241
256
  if (heartbeat) { heartbeat.stop(); }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @module abort-result
3
+ * The abort-result document builder for `abort <taskId|--all> --json` (B21-rest).
4
+ * Split out of result-schema.js purely to stay under the size gate — same
5
+ * versioning contract (fields only ADDED within a SCHEMA_VERSION) applies here.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { SCHEMA_VERSION } = require('./result-schema-version');
11
+
12
+ /**
13
+ * Build an abort-result document.
14
+ * `ok` is true iff at least one session/leg was actually marked aborted by this
15
+ * call — a no-op (nothing running) is a successful call with an empty list, but
16
+ * a specific taskId that exists yet was not running (already terminal) reports
17
+ * ok:false so a scripted caller can tell "nothing happened" from "you aborted N".
18
+ * @param {object} opts
19
+ * @param {'session'|'wave'|'all'} opts.scope
20
+ * @param {string|null} opts.taskId - null for scope:'all'
21
+ * @param {string[]} opts.aborted - ids actually marked aborted (session/wave id + any legs)
22
+ * @returns {object} abort document
23
+ */
24
+ function buildAbortResult({ scope, taskId = null, aborted = [] }) {
25
+ return {
26
+ schemaVersion: SCHEMA_VERSION,
27
+ type: 'abort',
28
+ ok: aborted.length > 0 || scope === 'all',
29
+ scope,
30
+ taskId,
31
+ aborted,
32
+ count: aborted.length,
33
+ };
34
+ }
35
+
36
+ module.exports = { buildAbortResult };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @module cli-preflight
3
+ * Tiny shared preflight guards used by more than one CLI run handler
4
+ * (start/resume/continue/fanout), split out so each handler file can stay
5
+ * under the size gate without duplicating the same few lines.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { failJson, ERROR_CODES } = require('./error-doc');
11
+ const { validateTaskId } = require('./validators');
12
+
13
+ /** Shared --json requires --no-ui gate. Exits (never returns) on violation. */
14
+ function requireNoUiForJson(args, useJson) {
15
+ if (args.json && !args['no-ui']) {
16
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Shared task-id presence + format check. Exits (never returns) on violation.
22
+ * @param {object} args - parsed CLI args (positional task id at args._[1])
23
+ * @param {boolean} useJson
24
+ * @param {string} commandLabel - e.g. 'resume', 'continue'
25
+ * @param {string} [usage] - appended to the missing-id message
26
+ * @returns {string} the validated task id
27
+ */
28
+ function requireValidTaskId(args, useJson, commandLabel, usage) {
29
+ const taskId = args._[1];
30
+ if (!taskId) {
31
+ process.exit(failJson(useJson, {
32
+ code: ERROR_CODES.BAD_SESSION,
33
+ message: `Error: task_id is required for ${commandLabel}${usage ? `\n${usage}` : ''}`,
34
+ }));
35
+ }
36
+ const check = validateTaskId(taskId);
37
+ if (!check.valid) {
38
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
39
+ }
40
+ return taskId;
41
+ }
42
+
43
+ module.exports = { requireNoUiForJson, requireValidTaskId };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module doctor-mcp-checks
3
+ * B14/Task 4.3: the two MCP-registration doctor checks ('mcp' and
4
+ * 'mcp-legacy'), split out of src/cli-handlers-doctor.js to keep that file
5
+ * under the 300-line size gate (mirrors how session-index-tmp-sweep.js holds
6
+ * the B15 sweep's evaluate* composer — src/cli-handlers-doctor.js just wraps
7
+ * these in guard() the same way).
8
+ *
9
+ * 'mcp' (evaluateMcpRegistration): PRIMARY signal is
10
+ * d.hasAmicusRegistration() — a RAW (unstripped) read of the same Claude
11
+ * Code sources discoverClaudeCodeMcps reads. discoverClaudeCodeMcps always
12
+ * strips every 'amicus'/'sidecar'-shaped entry as its own recursive-spawn
13
+ * guard (src/utils/mcp-self-identity.js), so testing `code.amicus` here
14
+ * would ALWAYS be false — that was the B14 false-negative. Cowork/Desktop
15
+ * discovery (d.discoverCoworkMcps) does not strip and stays a bonus signal.
16
+ *
17
+ * 'mcp-legacy' (evaluateLegacyMcpEntry): unchanged logic, moved verbatim.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const HINTS = require('./remediation-hints');
23
+
24
+ /**
25
+ * @param {{hasAmicusRegistration: () => boolean, discoverCoworkMcps: () => object|null}} d
26
+ */
27
+ function evaluateMcpRegistration(d) {
28
+ const id = 'mcp'; const name = 'MCP registration';
29
+ const inCode = !!d.hasAmicusRegistration();
30
+ const cowork = d.discoverCoworkMcps();
31
+ const inCowork = !!(cowork && cowork.amicus);
32
+ // Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
33
+ if (!inCode) {
34
+ return { id, name, status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
35
+ }
36
+ const extra = inCowork ? ', Cowork/Desktop' : '';
37
+ return { id, name, status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
38
+ }
39
+
40
+ /**
41
+ * Duplicate legacy 'sidecar' MCP registration (same server twice — doubles
42
+ * the client-visible tool list). Detection reads the raw config files via
43
+ * legacy-mcp-migration: mcp-discovery can't see it (it strips 'sidecar' as
44
+ * its own recursion guard). --fix removes only identical-in-effect twins.
45
+ * @param {{inspectLegacyMcpEntries: () => Array, fix?: boolean, migrateLegacyMcpEntries: () => Array}} d
46
+ */
47
+ function evaluateLegacyMcpEntry(d) {
48
+ const id = 'mcp-legacy'; const name = 'Legacy sidecar MCP entry';
49
+ const entries = d.inspectLegacyMcpEntries() || [];
50
+ const dupes = entries.filter(e => e.status === 'removable');
51
+ const custom = entries.filter(e => e.status === 'customized');
52
+ // An unreadable config is neither "no problem" nor a duplicate we can act
53
+ // on — reporting it as ok/'none' would hide a config doctor (and --fix)
54
+ // could not actually inspect. Always surface it, even alongside dupes.
55
+ const unreadable = entries.filter(e => e.status === 'unreadable');
56
+ const unreadableNote = unreadable.length
57
+ ? `${unreadable.map(e => e.target).join(', ')} config unreadable — skipped`
58
+ : null;
59
+ if (dupes.length === 0) {
60
+ if (unreadableNote) {
61
+ const suffix = custom.length ? `; custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone` : '';
62
+ return { id, name, status: 'warn', message: `${unreadableNote}${suffix}`, hint: null };
63
+ }
64
+ const message = custom.length
65
+ ? `custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone`
66
+ : 'none';
67
+ return { id, name, status: 'ok', message, hint: null };
68
+ }
69
+ if (d.fix) {
70
+ const removed = (d.migrateLegacyMcpEntries() || []).filter(r => r.result === 'removed');
71
+ if (removed.length >= dupes.length) {
72
+ const message = `removed legacy entry from: ${removed.map(r => r.target).join(', ')}`;
73
+ return unreadableNote
74
+ ? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar }
75
+ : { id, name, status: 'ok', message, hint: null };
76
+ }
77
+ const message = `removed ${removed.length}/${dupes.length} duplicate(s) — could not update every config`;
78
+ return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
79
+ }
80
+ const message = `duplicate 'sidecar' entry in ${dupes.map(e => e.target).join(', ')} — doubles the MCP tool list`;
81
+ return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
82
+ }
83
+
84
+ module.exports = { evaluateMcpRegistration, evaluateLegacyMcpEntry };
@@ -124,4 +124,55 @@ function validateStartInputs(input) {
124
124
  return { valid: true, resolvedModel: resolved };
125
125
  }
126
126
 
127
- module.exports = { validateStartInputs, findSimilar };
127
+ /**
128
+ * Levenshtein edit distance between two strings (insertions, deletions,
129
+ * substitutions, each cost 1). Hand-rolled — no runtime dependency added,
130
+ * since fast-levenshtein is only a dev-time transitive and runtime deps are
131
+ * locked for this project.
132
+ * @param {string} a
133
+ * @param {string} b
134
+ * @returns {number}
135
+ */
136
+ function levenshteinDistance(a, b) {
137
+ const m = a.length;
138
+ const n = b.length;
139
+ if (m === 0) { return n; }
140
+ if (n === 0) { return m; }
141
+
142
+ // Single rolling row (O(min(m,n)) space) rather than a full m×n matrix —
143
+ // plenty for CLI command names, which are always short.
144
+ let prevRow = Array.from({ length: n + 1 }, (_, j) => j);
145
+ for (let i = 1; i <= m; i++) {
146
+ const currRow = [i];
147
+ for (let j = 1; j <= n; j++) {
148
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
149
+ currRow[j] = Math.min(
150
+ prevRow[j] + 1, // deletion
151
+ currRow[j - 1] + 1, // insertion
152
+ prevRow[j - 1] + cost // substitution
153
+ );
154
+ }
155
+ prevRow = currRow;
156
+ }
157
+ return prevRow[n];
158
+ }
159
+
160
+ /**
161
+ * Suggest known commands close to an unrecognized one ("did you mean").
162
+ * @param {string} input - the unrecognized token the user typed
163
+ * @param {string[]} candidates - known command names
164
+ * @param {number} [maxDistance=2] - inclusive distance cap
165
+ * @param {number} [maxSuggestions=3]
166
+ * @returns {string[]} candidates within maxDistance, closest first, capped
167
+ */
168
+ function suggestCommand(input, candidates, maxDistance = 2, maxSuggestions = 3) {
169
+ if (!input) { return []; }
170
+ return candidates
171
+ .map(c => ({ c, distance: levenshteinDistance(input.toLowerCase(), c.toLowerCase()) }))
172
+ .filter(({ distance }) => distance <= maxDistance)
173
+ .sort((a, b) => a.distance - b.distance)
174
+ .slice(0, maxSuggestions)
175
+ .map(({ c }) => c);
176
+ }
177
+
178
+ module.exports = { validateStartInputs, findSimilar, levenshteinDistance, suggestCommand };
@@ -12,7 +12,7 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const os = require('os');
14
14
  const { logger } = require('./logger');
15
- const { stripSelfMcpEntries } = require('./mcp-self-identity');
15
+ const { stripSelfMcpEntries, isAmicusMcpConfig } = require('./mcp-self-identity');
16
16
 
17
17
  /**
18
18
  * Normalize .mcp.json to a flat { name: config } map.
@@ -34,17 +34,16 @@ function normalizeMcpJson(raw) {
34
34
  }
35
35
 
36
36
  /**
37
- * Discover MCP servers from Claude Code's plugin chain AND ~/.claude.json.
38
- *
39
- * Discovery sources (merged, in priority order):
40
- * 1. ~/.claude.json mcpServers (servers added via `claude mcp add`)
41
- * 2. Enabled plugins → .mcp.json entries
37
+ * Read Claude Code's merged mcpServers map (~/.claude.json + plugin-chain
38
+ * .mcp.json files) WITHOUT the self-entry strip. Shared raw-read core for
39
+ * both discoverClaudeCodeMcps (strips) and hasAmicusRegistration (does not —
40
+ * it needs to SEE the amicus entry the strip would otherwise hide).
42
41
  *
43
42
  * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
44
43
  * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
45
- * @returns {object|null} Merged MCP server configs, or null if none found
44
+ * @returns {object} Merged MCP server configs (never stripped); {} if none found
46
45
  */
47
- function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
46
+ function readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath) {
48
47
  const baseDir = claudeDir || path.join(os.homedir(), '.claude');
49
48
  const jsonPath = claudeJsonPath || path.join(os.homedir(), '.claude.json');
50
49
 
@@ -71,14 +70,12 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
71
70
  const settingsPath = path.join(baseDir, 'settings.json');
72
71
  if (!fs.existsSync(settingsPath)) {
73
72
  // No settings.json — skip plugin discovery, may still have claude.json servers
74
- const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
75
- return Object.keys(merged).length > 0 ? merged : null;
73
+ return { ...claudeJsonServers };
76
74
  }
77
75
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
78
76
  const enabledPlugins = settings.enabledPlugins;
79
77
  if (!enabledPlugins || typeof enabledPlugins !== 'object') {
80
- const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
81
- return Object.keys(merged).length > 0 ? merged : null;
78
+ return { ...claudeJsonServers };
82
79
  }
83
80
 
84
81
  let installedPlugins = {};
@@ -133,12 +130,51 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
133
130
  }
134
131
 
135
132
  // Merge: plugin servers first, then claude.json overwrites (higher priority).
136
- // Recursive-spawn guard: drop every entry that resolves to amicus itself.
137
- const merged = stripSelfMcpEntries({ ...pluginServers, ...claudeJsonServers }, logger);
133
+ return { ...pluginServers, ...claudeJsonServers };
134
+ }
138
135
 
136
+ /**
137
+ * Discover MCP servers from Claude Code's plugin chain AND ~/.claude.json.
138
+ *
139
+ * Discovery sources (merged, in priority order):
140
+ * 1. ~/.claude.json → mcpServers (servers added via `claude mcp add`)
141
+ * 2. Enabled plugins → .mcp.json entries
142
+ *
143
+ * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
144
+ * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
145
+ * @returns {object|null} Merged MCP server configs, or null if none found
146
+ */
147
+ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
148
+ // Recursive-spawn guard: drop every entry that resolves to amicus itself.
149
+ const merged = stripSelfMcpEntries(readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath), logger);
139
150
  return Object.keys(merged).length > 0 ? merged : null;
140
151
  }
141
152
 
153
+ /**
154
+ * True when Claude Code already has a working amicus MCP registration —
155
+ * checked against the SAME raw sources discoverClaudeCodeMcps reads, but
156
+ * WITHOUT stripSelfMcpEntries. discoverClaudeCodeMcps strips every
157
+ * 'amicus'/'sidecar'-shaped entry as a recursive-spawn guard (src/utils/
158
+ * mcp-self-identity.js), so code.amicus is ALWAYS undefined downstream —
159
+ * that check is the wrong consumer to answer "is amicus registered?" (B14).
160
+ *
161
+ * True when any entry's key is literally 'amicus' (regardless of its value
162
+ * shape — an unrecognizable value under that key is still an amicus
163
+ * registration slot) OR its value passes isAmicusMcpConfig() (covers
164
+ * aliased keys, e.g. legacy 'sidecar' or a custom name, whose command/args
165
+ * resolve to an amicus MCP invocation).
166
+ *
167
+ * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
168
+ * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
169
+ * @returns {boolean}
170
+ */
171
+ function hasAmicusRegistration(claudeDir, claudeJsonPath) {
172
+ const servers = readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath) || {};
173
+ return Object.entries(servers).some(([name, config]) => (
174
+ name === 'amicus' || isAmicusMcpConfig(config)
175
+ ));
176
+ }
177
+
142
178
  /**
143
179
  * Resolve Claude Desktop's per-platform config directory.
144
180
  * Mirrors the 3-way branch in src/environment.js getCoworkRoot (same
@@ -211,5 +247,6 @@ module.exports = {
211
247
  discoverParentMcps,
212
248
  discoverClaudeCodeMcps,
213
249
  discoverCoworkMcps,
250
+ hasAmicusRegistration,
214
251
  normalizeMcpJson
215
252
  };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module result-schema-version
3
+ * The single SCHEMA_VERSION constant shared by result-schema.js and
4
+ * abort-result.js (split out to avoid a circular require between them).
5
+ *
6
+ * Stability contract: fields on any doc built from this version are only
7
+ * ADDED within a SCHEMA_VERSION; any rename/removal bumps SCHEMA_VERSION.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const SCHEMA_VERSION = 2;
13
+
14
+ module.exports = { SCHEMA_VERSION };