amicus 2.0.0 → 2.1.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 +62 -0
- package/README.md +1 -1
- package/bin/amicus.js +11 -93
- package/commands/council.md +7 -5
- package/package.json +1 -1
- package/skills/second-opinion/SKILL.md +6 -5
- package/skills/sidecar/SKILL.md +17 -14
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-doctor.js +13 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +5 -4
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +20 -0
- package/src/mcp-server.js +8 -5
- package/src/mcp-tools.js +31 -21
- package/src/sidecar/continue.js +23 -8
- package/src/sidecar/resume.js +23 -8
- package/src/utils/abort-result.js +36 -0
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/input-validators.js +52 -1
- package/src/utils/mcp-discovery.js +51 -14
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +10 -10
package/src/sidecar/resume.js
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
44
|
+
* @returns {object} Merged MCP server configs (never stripped); {} if none found
|
|
46
45
|
*/
|
|
47
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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 };
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
// src/utils/result-schema.js
|
|
2
1
|
'use strict';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* @module result-schema
|
|
6
5
|
* Versioned, machine-parseable result documents for `--json` output (F4).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Stability contract: fields are only ADDED within a SCHEMA_VERSION; any
|
|
7
|
+
* rename/removal bumps SCHEMA_VERSION (defined in ./result-schema-version.js,
|
|
8
|
+
* split out so ./abort-result.js can depend on it without a circular require).
|
|
10
9
|
*/
|
|
11
|
-
|
|
12
|
-
const SCHEMA_VERSION = 2;
|
|
10
|
+
const { SCHEMA_VERSION } = require('./result-schema-version');
|
|
13
11
|
|
|
14
12
|
/** Leg/run statuses that count as terminal for wave aggregation. */
|
|
15
13
|
const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
|
|
@@ -159,8 +157,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
|
|
|
159
157
|
* @param {string} project - Project dir
|
|
160
158
|
* @param {string} taskId
|
|
161
159
|
* @returns {object} run document
|
|
162
|
-
* @throws {Error} if the session does not exist
|
|
163
|
-
* @throws {Error} if metadata.json is missing or corrupt
|
|
160
|
+
* @throws {Error} if the session does not exist or metadata.json is missing/corrupt
|
|
164
161
|
*/
|
|
165
162
|
function buildRunResultFromSession(project, taskId) {
|
|
166
163
|
const fs = require('fs');
|
|
@@ -189,8 +186,7 @@ function buildRunResultFromSession(project, taskId) {
|
|
|
189
186
|
* @param {string} project
|
|
190
187
|
* @param {string} waveId
|
|
191
188
|
* @returns {object} wave document
|
|
192
|
-
* @throws {Error} if the wave session does not exist
|
|
193
|
-
* @throws {Error} if metadata.json is missing or corrupt
|
|
189
|
+
* @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
|
|
194
190
|
*/
|
|
195
191
|
function buildWaveResultFromSession(project, waveId) {
|
|
196
192
|
const fs = require('fs');
|
|
@@ -283,6 +279,9 @@ function buildDoctorDoc({ version, timestamp, checks }) {
|
|
|
283
279
|
};
|
|
284
280
|
}
|
|
285
281
|
|
|
282
|
+
// buildAbortResult lives in ./abort-result.js (size-gate split); re-exported below.
|
|
283
|
+
const { buildAbortResult } = require('./abort-result');
|
|
284
|
+
|
|
286
285
|
module.exports = {
|
|
287
286
|
SCHEMA_VERSION,
|
|
288
287
|
TERMINAL_STATUSES,
|
|
@@ -297,4 +296,5 @@ module.exports = {
|
|
|
297
296
|
buildCatalogDoc,
|
|
298
297
|
buildAuditDoc,
|
|
299
298
|
buildDoctorDoc,
|
|
299
|
+
buildAbortResult,
|
|
300
300
|
};
|