amicus 4.5.4 → 4.6.1
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 +113 -0
- package/README.md +1 -1
- package/commands/council.md +1 -1
- package/docs/DISTRIBUTION.md +38 -11
- package/docs/ROADMAP.md +40 -8
- package/docs/publishing.md +1 -1
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-run.schema.json +20 -0
- package/schemas/council-verdict.schema.json +20 -0
- package/schemas/doctor.schema.json +23 -1
- package/skills/second-opinion/MODEL-NOTES.md +182 -35
- package/src/cli-council-run-render.js +51 -0
- package/src/cli-handlers-council-run.js +45 -44
- package/src/cli-handlers-council.js +9 -3
- package/src/cli-handlers-doctor.js +16 -37
- package/src/cli-handlers-watch.js +1 -1
- package/src/cli.js +1 -1
- package/src/council/ledger.js +5 -1
- package/src/council/report-html.js +16 -1
- package/src/council/report.js +25 -1
- package/src/council/run-assemble.js +25 -7
- package/src/council/run-budget.js +14 -8
- package/src/council/run-chair.js +21 -4
- package/src/council/run-debate-stage.js +115 -0
- package/src/council/run-degrade.js +44 -0
- package/src/council/run-finalize.js +18 -3
- package/src/council/run-launch.js +4 -0
- package/src/council/run-retry-notes.js +74 -0
- package/src/council/run-retry.js +280 -0
- package/src/council/run-server.js +24 -7
- package/src/council/run-stage2.js +10 -2
- package/src/council/run-stages.js +59 -27
- package/src/council/run.js +39 -67
- package/src/council/verdict.js +81 -8
- package/src/mcp-council-bench.js +45 -0
- package/src/mcp-council-run.js +11 -28
- package/src/mcp-server.js +22 -3
- package/src/mcp-tools.js +13 -1
- package/src/utils/degrade.js +69 -0
- package/src/utils/doctor-degrade.js +51 -0
- package/src/utils/doctor-electron-mcp-check.js +64 -5
- package/src/utils/doctor-engine-check.js +14 -3
- package/src/utils/doctor-mcp-checks.js +10 -3
- package/src/utils/known-flags.js +2 -1
- package/src/utils/remediation-hints.js +20 -14
- package/src/utils/result-schema.js +6 -2
- package/src/utils/session-index-tmp-sweep.js +2 -1
- package/src/utils/update-notice.js +171 -0
- package/src/workspace/run-scan.js +5 -1
|
@@ -113,7 +113,7 @@ function evaluateElectronInstalls(d) {
|
|
|
113
113
|
* exe — then re-report from a fresh scan. package-missing copies are never
|
|
114
114
|
* repaired (no package to repair into). Mirrors evaluateEngineMcp.
|
|
115
115
|
* @param {object} d doctor deps (scanElectronInstalls, fix?, repairElectron?, fixTimeoutMs?)
|
|
116
|
-
* @returns {Promise<{id,name,status,message,hint}>}
|
|
116
|
+
* @returns {Promise<{id,name,status,message,hint,fixed?,fixDetail?}>}
|
|
117
117
|
*/
|
|
118
118
|
async function evaluateElectronMcp(d) {
|
|
119
119
|
const verdict = evaluateElectronInstalls(d);
|
|
@@ -138,13 +138,72 @@ async function evaluateElectronMcp(d) {
|
|
|
138
138
|
const after = evaluateElectronInstalls(d); // fresh scan reflects the repairs
|
|
139
139
|
if (after.status === 'ok') {
|
|
140
140
|
const n = results.length;
|
|
141
|
-
return {
|
|
141
|
+
return {
|
|
142
|
+
...after,
|
|
143
|
+
message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})`,
|
|
144
|
+
fixed: true,
|
|
145
|
+
fixDetail: `self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}`,
|
|
146
|
+
};
|
|
142
147
|
}
|
|
143
148
|
const failed = results.filter((r) => !r.repaired)
|
|
144
149
|
.map((r) => `${r.electronDir}${r.reason ? ` — ${r.reason}` : ''}`).join('; ');
|
|
150
|
+
// Partial credit: some copies healed even though the check overall is still
|
|
151
|
+
// not 'ok' — flag it ONLY when >=1 repair actually succeeded (#84-style rule).
|
|
152
|
+
const healed = results.filter((r) => r.repaired).length;
|
|
153
|
+
const fixFields = healed > 0
|
|
154
|
+
? { fixed: true, fixDetail: `self-healed ${healed} npx-cache ${plural(healed, 'copy', 'copies')}` }
|
|
155
|
+
: {};
|
|
145
156
|
return failed
|
|
146
|
-
? { ...after, message: `${after.message}; self-heal incomplete: ${failed}
|
|
147
|
-
: after;
|
|
157
|
+
? { ...after, message: `${after.message}; self-heal incomplete: ${failed}`, ...fixFields }
|
|
158
|
+
: { ...after, ...fixFields };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The interactive-GUI electron check (`id: 'electron'`), moved verbatim from
|
|
163
|
+
* cli-handlers-doctor.js (v4.6 Plan 3 Task 1) for the 300-line gate — this
|
|
164
|
+
* file already owns the electron-flavored doctor logic (#76). Behavior
|
|
165
|
+
* identical; the only change is `fixTimeoutMs` arriving as a parameter
|
|
166
|
+
* instead of a closure constant.
|
|
167
|
+
* @param {object} d doctor deps (getElectronPath, fix?, repairElectron)
|
|
168
|
+
* @param {{fixTimeoutMs: number}} opts
|
|
169
|
+
* @returns {Promise<{id,name,status,message,hint,fixed?,fixDetail?}>}
|
|
170
|
+
*/
|
|
171
|
+
async function evaluateElectronInteractive(d, { fixTimeoutMs }) {
|
|
172
|
+
if (d.getElectronPath()) {
|
|
173
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
|
|
174
|
+
}
|
|
175
|
+
// Broken (missing / quarantined). With --fix, self-heal in place (#56):
|
|
176
|
+
// repairElectron provisions the binary; {deferred} (no cache, no network)
|
|
177
|
+
// maps to WARN — a deferred download is not a failure. Without --fix, just
|
|
178
|
+
// point the user at `amicus doctor --fix`.
|
|
179
|
+
if (d.fix) {
|
|
180
|
+
let res;
|
|
181
|
+
try {
|
|
182
|
+
res = await d.repairElectron({ timeoutMs: fixTimeoutMs });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
|
|
185
|
+
}
|
|
186
|
+
res = res || {};
|
|
187
|
+
if (res.repaired) {
|
|
188
|
+
return {
|
|
189
|
+
id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null,
|
|
190
|
+
fixed: true, fixDetail: 'provisioned the Electron binary in place',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const why = res.reason ? ` — ${res.reason}` : '';
|
|
194
|
+
// Quarantine (AV deleted electron.exe post-extract) is NOT a deferral and
|
|
195
|
+
// must NEVER be silently retried: surface the allow-list instruction as a
|
|
196
|
+
// WARN and STOP. No re-run of repairElectron here (no loop).
|
|
197
|
+
const detail = res.quarantined
|
|
198
|
+
? `antivirus quarantine${why}`
|
|
199
|
+
: res.deferred
|
|
200
|
+
? `deferred${why}`
|
|
201
|
+
: res.contended
|
|
202
|
+
? `repair already in progress${why}`
|
|
203
|
+
: `not provisioned${why}`;
|
|
204
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
|
|
205
|
+
}
|
|
206
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
|
|
148
207
|
}
|
|
149
208
|
|
|
150
|
-
module.exports = { scanElectronInstalls, evaluateElectronInstalls, evaluateElectronMcp };
|
|
209
|
+
module.exports = { scanElectronInstalls, evaluateElectronInstalls, evaluateElectronMcp, evaluateElectronInteractive };
|
|
@@ -73,7 +73,7 @@ function evaluateEngineInstalls(d) {
|
|
|
73
73
|
* the engine into each via d.repairEngine, then re-report from a fresh scan.
|
|
74
74
|
* Without d.fix — or when nothing is copy-fixable — returns the plain verdict.
|
|
75
75
|
* @param {object} d doctor deps (scanEngineInstalls, fix?, repairEngine?)
|
|
76
|
-
* @returns {Promise<{id,name,status,message,hint}>}
|
|
76
|
+
* @returns {Promise<{id,name,status,message,hint,fixed?,fixDetail?}>}
|
|
77
77
|
*/
|
|
78
78
|
async function evaluateEngineMcp(d) {
|
|
79
79
|
const verdict = evaluateEngineInstalls(d);
|
|
@@ -94,11 +94,22 @@ async function evaluateEngineMcp(d) {
|
|
|
94
94
|
const after = evaluateEngineInstalls(d); // fresh scan reflects the copies
|
|
95
95
|
if (after.status === 'ok') {
|
|
96
96
|
const n = results.length;
|
|
97
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
...after,
|
|
99
|
+
message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})`,
|
|
100
|
+
fixed: true,
|
|
101
|
+
fixDetail: `copied the engine into ${n} npx-cache ${plural(n, 'copy', 'copies')}`,
|
|
102
|
+
};
|
|
98
103
|
}
|
|
99
104
|
const failed = results.filter((r) => !r.repaired)
|
|
100
105
|
.map((r) => `${r.pkgDir}${r.reason ? ` — ${r.reason}` : ''}`).join('; ');
|
|
101
|
-
|
|
106
|
+
// Partial credit: some copies healed even though the check overall is still
|
|
107
|
+
// not 'ok' — flag it ONLY when >=1 repair actually succeeded (#84-style rule).
|
|
108
|
+
const healed = results.filter((r) => r.repaired).length;
|
|
109
|
+
const fixFields = healed > 0
|
|
110
|
+
? { fixed: true, fixDetail: `copied the engine into ${healed} npx-cache ${plural(healed, 'copy', 'copies')}` }
|
|
111
|
+
: {};
|
|
112
|
+
return { ...after, message: `${after.message}; self-heal incomplete: ${failed}`, ...fixFields };
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
module.exports = { evaluateEngineInstalls, evaluateEngineMcp };
|
|
@@ -68,14 +68,21 @@ function evaluateLegacyMcpEntry(d) {
|
|
|
68
68
|
}
|
|
69
69
|
if (d.fix) {
|
|
70
70
|
const removed = (d.migrateLegacyMcpEntries() || []).filter(r => r.result === 'removed');
|
|
71
|
+
// Structured fix outcome (v4.6 Plan 3 Task 3): a repaired row carries
|
|
72
|
+
// fixed/fixDetail whenever ANY entry was actually removed, even on the
|
|
73
|
+
// partial-failure path below — a genuine no-op (removed.length === 0)
|
|
74
|
+
// stays unflagged.
|
|
75
|
+
const fixFields = removed.length > 0
|
|
76
|
+
? { fixed: true, fixDetail: `removed the duplicate legacy 'sidecar' entry from ${removed.map(r => r.target).join(', ')}` }
|
|
77
|
+
: {};
|
|
71
78
|
if (removed.length >= dupes.length) {
|
|
72
79
|
const message = `removed legacy entry from: ${removed.map(r => r.target).join(', ')}`;
|
|
73
80
|
return unreadableNote
|
|
74
|
-
? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar }
|
|
75
|
-
: { id, name, status: 'ok', message, hint: null };
|
|
81
|
+
? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar, ...fixFields }
|
|
82
|
+
: { id, name, status: 'ok', message, hint: null, ...fixFields };
|
|
76
83
|
}
|
|
77
84
|
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 };
|
|
85
|
+
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar, ...fixFields };
|
|
79
86
|
}
|
|
80
87
|
const message = `duplicate 'sidecar' entry in ${dupes.map(e => e.target).join(', ')} — doubles the MCP tool list`;
|
|
81
88
|
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
package/src/utils/known-flags.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* Real flags that appear in NO usage block. Rejecting any of these would break
|
|
30
30
|
* working callers, so they are enumerated deliberately rather than derived.
|
|
31
31
|
*
|
|
32
|
-
* ⚠️
|
|
32
|
+
* ⚠️ These are spawned by the MCP server onto its own CLI children
|
|
33
33
|
* (src/mcp-server.js, src/mcp-council-run.js). They are not user-facing and are
|
|
34
34
|
* intentionally undocumented — but they are on the argv of every MCP-launched
|
|
35
35
|
* run, so rejecting them would break the entire MCP surface.
|
|
@@ -39,6 +39,7 @@ const INTERNAL_FLAGS = new Set([
|
|
|
39
39
|
'run-id', // MCP → `council run`: pins the child's run id
|
|
40
40
|
'council-name', // MCP → `council run`: preset name for ledger attribution
|
|
41
41
|
'cowork-process', // MCP → `start`: Cowork process handle for context capture
|
|
42
|
+
'dropped-members', // MCP → 'council run': per-member preset drops as JSON (v4.6 Plan 4)
|
|
42
43
|
|
|
43
44
|
// User-facing but undocumented, and read by real handlers today. Listed so the
|
|
44
45
|
// rejection is a bug fix and not a silent removal of working behaviour; if any
|
|
@@ -33,7 +33,7 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
33
33
|
*/
|
|
34
34
|
reinstallEngineAv:
|
|
35
35
|
'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus). '
|
|
36
|
-
+ 'If your antivirus (e.g. Windows Defender) quarantined opencode.exe, allow-list it first, then reinstall.',
|
|
36
|
+
+ 'If your antivirus (e.g. Windows Defender) quarantined opencode.exe — unverified, but a known cause — allow-list it first, then reinstall.',
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Runtime server-start failure when the opencode engine binary does not
|
|
@@ -41,27 +41,27 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
41
41
|
* spawn ENOENT — surfaced by startServer (the missing-binary boundary).
|
|
42
42
|
*/
|
|
43
43
|
engineMissing:
|
|
44
|
-
'OpenCode engine binary not found —
|
|
45
|
-
+ '
|
|
44
|
+
'OpenCode engine binary not found — the cause was not verified. Common causes (unverified): '
|
|
45
|
+
+ 'an install that skipped or rolled back the engine packages, or antivirus quarantine of opencode.exe. '
|
|
46
|
+
+ 'Run "amicus doctor" to check the actual install, reinstall with "npm i -g amicus", '
|
|
47
|
+
+ 'and allow-list opencode.exe in your antivirus if quarantine was the cause.',
|
|
46
48
|
|
|
47
49
|
/** Electron absent — reinstall to add the interactive GUI (headless still works). */
|
|
48
50
|
reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
|
|
49
51
|
|
|
50
|
-
/**
|
|
51
|
-
* Electron present but broken (ABI mismatch / partial unpack). Delete the
|
|
52
|
-
* vendored copy and reinstall to force a clean rebuild.
|
|
53
|
-
*/
|
|
54
|
-
rebuildElectron:
|
|
55
|
-
'rm -rf node_modules/electron && npm install -g amicus (rebuild Electron after an ABI mismatch or partial unpack)',
|
|
56
|
-
|
|
57
52
|
/** Point the user at the single recovery hub. */
|
|
58
53
|
runDoctor: 'run: amicus doctor (diagnoses config, keys, engine & MCP, with copy-paste fixes)',
|
|
59
54
|
|
|
60
55
|
/**
|
|
61
|
-
* Self-heal the optional Electron GUI in place (#56).
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
56
|
+
* Self-heal the optional Electron GUI in place (#56). The convergence target
|
|
57
|
+
* for the "reinstall to fix Electron" hints — it provisions the binary from
|
|
58
|
+
* cache (or downloads on demand) WITHOUT a global reinstall, so it can't
|
|
59
|
+
* loop the way `npm install -g amicus` could when the rollback recurs.
|
|
60
|
+
* (`rebuildElectron`, the manual rm-rf-and-reinstall variant, was deleted
|
|
61
|
+
* 2026-08-03 by owner ruling: no live call site once this hint became the
|
|
62
|
+
* target, and its prose asserted unverified causes. A reintroduction must
|
|
63
|
+
* use the unverified-cause voice — absence-pinned in
|
|
64
|
+
* tests/remediation-hints.test.js.)
|
|
65
65
|
*/
|
|
66
66
|
doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
|
|
67
67
|
|
|
@@ -78,6 +78,12 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
78
78
|
* atomic tmp-write and rename leaves a stray temp file in the config dir
|
|
79
79
|
* forever. `doctor --fix` sweeps files older than 60s (never a live writer's
|
|
80
80
|
* ms-lived tmp).
|
|
81
|
+
*
|
|
82
|
+
* Voice ruling (Christian, 2026-08-03): this hint keeps its confident cause.
|
|
83
|
+
* "Left by an interrupted write" is definitional, not a guess — the atomic
|
|
84
|
+
* write pattern admits no other producer, and the age gate excludes live
|
|
85
|
+
* writers — so the Plan 3 unverified-cause voice deliberately does NOT
|
|
86
|
+
* apply. Do not re-file it against that criterion.
|
|
81
87
|
*/
|
|
82
88
|
sweepSessionIndexTmp:
|
|
83
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
@@ -209,9 +209,10 @@ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Build a doctor health-check document (`doctor --json`).
|
|
212
|
-
* @param {{version: string, timestamp: string, checks: Array<{id,name,status,message,hint}
|
|
212
|
+
* @param {{version: string, timestamp: string, checks: Array<{id,name,status,message,hint}>,
|
|
213
|
+
* degrades?: Array<{kind,channel,what,why,effect,remedy?,data?}>}} opts
|
|
213
214
|
*/
|
|
214
|
-
function buildDoctorDoc({ version, timestamp, checks }) {
|
|
215
|
+
function buildDoctorDoc({ version, timestamp, checks, degrades }) {
|
|
215
216
|
return {
|
|
216
217
|
schemaVersion: SCHEMA_VERSION,
|
|
217
218
|
type: 'doctor',
|
|
@@ -219,6 +220,9 @@ function buildDoctorDoc({ version, timestamp, checks }) {
|
|
|
219
220
|
version,
|
|
220
221
|
timestamp,
|
|
221
222
|
checks,
|
|
223
|
+
// v4.6 Plan 3 (spec §4/§6): the shared-vocabulary surface. Additive and
|
|
224
|
+
// OPTIONAL — present only when a check failed or --fix repaired something.
|
|
225
|
+
...(degrades && degrades.length ? { degrades } : {}),
|
|
222
226
|
};
|
|
223
227
|
}
|
|
224
228
|
|
|
@@ -70,7 +70,8 @@ function evaluateSessionIndexTmpSweep(d) {
|
|
|
70
70
|
}
|
|
71
71
|
const remaining = files.length - swept;
|
|
72
72
|
if (remaining === 0) {
|
|
73
|
-
|
|
73
|
+
const fixFields = swept > 0 ? { fixed: true, fixDetail: `swept ${swept} orphaned session-index tmp file(s)` } : {};
|
|
74
|
+
return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null, ...fixFields };
|
|
74
75
|
}
|
|
75
76
|
return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionIndexTmp };
|
|
76
77
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/update-notice — "a newer amicus exists" for the MCP channel
|
|
3
|
+
*
|
|
4
|
+
* The MCP server is the one entry point that skips bin/amicus.js's update
|
|
5
|
+
* banner (deliberately — stdout is protocol). This module is the MCP-shaped
|
|
6
|
+
* replacement (spec docs/superpowers/specs/2026-08-03-mcp-update-notice-design.md):
|
|
7
|
+
* updater.js's cached check rendered as ONE appended text content block on the
|
|
8
|
+
* first successful tool result of the process (latched, D1), plus an always-on
|
|
9
|
+
* line in amicus_guide.
|
|
10
|
+
*
|
|
11
|
+
* Voice contract (v4.6 hint ruling): the version pair is verified fact; the
|
|
12
|
+
* upgrade instruction is stated as fact only when derived from a readable MCP
|
|
13
|
+
* registration config — fallbacks keep the "likely" hedge. Everything here is
|
|
14
|
+
* advisory: every export swallows its own failures rather than throwing into
|
|
15
|
+
* a tool result.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/** Upgrade wordings (spec §4). Config-derived rows are verified-voiced;
|
|
21
|
+
* NPX_CACHED_LINE keeps the hedge — the config read is best-effort. */
|
|
22
|
+
const GLOBAL_LINE = 'Run `npm install -g amicus`, then restart your MCP client.';
|
|
23
|
+
const NPX_LATEST_LINE = 'Restart your MCP client — it launches `amicus@latest` and will pick up the new version.';
|
|
24
|
+
const NPX_CACHED_LINE = 'Your MCP config likely launches a cached/pinned npx copy; '
|
|
25
|
+
+ 'point it at `npx -y amicus@latest mcp` (or clear the npx cache), then restart your MCP client.';
|
|
26
|
+
const GENERIC_LINE = 'Upgrade your amicus install, then restart your MCP client.';
|
|
27
|
+
|
|
28
|
+
const CHANGELOG_URL = 'https://github.com/BourbonDog/amicus/blob/main/CHANGELOG.md';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Flavor of THIS install — the copy serving the current process. Pure path
|
|
32
|
+
* heuristic on the realpath of our own package.json (no `npm root -g` shellout
|
|
33
|
+
* on the tool-result path): a `_npx` segment is the npx cache; any other
|
|
34
|
+
* `node_modules` home is a global-style install; no `node_modules` at all is a
|
|
35
|
+
* dev clone or similar.
|
|
36
|
+
* @param {{fs?: object, pkgPath?: string}} [deps]
|
|
37
|
+
* @returns {'global'|'npx'|'other'}
|
|
38
|
+
*/
|
|
39
|
+
function classifySelfInstall(deps = {}) {
|
|
40
|
+
const fs = deps.fs || require('fs');
|
|
41
|
+
const pkgPath = deps.pkgPath || require('./version-info').PKG_PATH;
|
|
42
|
+
try {
|
|
43
|
+
// Split the raw realpath — NOT path.dirname first: dirname is platform-
|
|
44
|
+
// bound (posix dirname collapses a foreign backslash path to '.', the CI
|
|
45
|
+
// path-fixture failure class), and the basename 'package.json' can never
|
|
46
|
+
// collide with the segment names probed here.
|
|
47
|
+
const parts = fs.realpathSync(pkgPath).split(/[\\/]/);
|
|
48
|
+
if (parts.includes('_npx')) { return 'npx'; }
|
|
49
|
+
if (parts.includes('node_modules')) { return 'global'; }
|
|
50
|
+
return 'other';
|
|
51
|
+
} catch {
|
|
52
|
+
return 'other';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when some RAW config arg is the amicus package token pinned `@latest`.
|
|
58
|
+
* Raw on purpose: mcp-self-identity's normalizeToken strips `@version`
|
|
59
|
+
* suffixes, which is exactly the information this check needs.
|
|
60
|
+
* @param {{args?: unknown[]}|null|undefined} config
|
|
61
|
+
*/
|
|
62
|
+
function pinsAmicusLatest(config) {
|
|
63
|
+
const args = Array.isArray(config && config.args) ? config.args : [];
|
|
64
|
+
return args.some((a) => {
|
|
65
|
+
const t = String(a).toLowerCase().replace(/\\/g, '/');
|
|
66
|
+
const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
|
|
67
|
+
return base === 'amicus@latest';
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one correct upgrade move for this install (spec §4), chosen
|
|
73
|
+
* config-first (what a RESTART will launch), self-path fallback.
|
|
74
|
+
* Never throws; worst case is the generic line.
|
|
75
|
+
* @param {{readConfig?: Function, classifyLaunch?: Function, selfFlavor?: Function}} [deps]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function upgradeInstruction(deps = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const readConfig = deps.readConfig
|
|
81
|
+
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
82
|
+
const classifyLaunchFn = deps.classifyLaunch
|
|
83
|
+
|| require('./engine-install-scan').classifyLaunch;
|
|
84
|
+
const selfFlavor = deps.selfFlavor || (() => classifySelfInstall(deps));
|
|
85
|
+
|
|
86
|
+
let config = null;
|
|
87
|
+
try { config = readConfig(); } catch { config = null; }
|
|
88
|
+
|
|
89
|
+
const launch = classifyLaunchFn(config);
|
|
90
|
+
if (launch === 'npx') {
|
|
91
|
+
return pinsAmicusLatest(config) ? NPX_LATEST_LINE : NPX_CACHED_LINE;
|
|
92
|
+
}
|
|
93
|
+
if (launch === 'path') {
|
|
94
|
+
// A path registration launches (approximately) the running copy — let
|
|
95
|
+
// its flavor pick between the npm-global move and the generic one.
|
|
96
|
+
return selfFlavor() === 'global' ? GLOBAL_LINE : GENERIC_LINE;
|
|
97
|
+
}
|
|
98
|
+
// 'none' / 'unknown' — config unreadable or unrecognized: self-path fallback.
|
|
99
|
+
const flavor = selfFlavor();
|
|
100
|
+
if (flavor === 'global') { return GLOBAL_LINE; }
|
|
101
|
+
if (flavor === 'npx') { return NPX_CACHED_LINE; }
|
|
102
|
+
return GENERIC_LINE;
|
|
103
|
+
} catch {
|
|
104
|
+
return GENERIC_LINE;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The full notice text: verified version pair + instruction + changelog.
|
|
110
|
+
* @param {{current: string, latest: string}} info
|
|
111
|
+
* @param {string} [instruction] - resolved lazily when omitted
|
|
112
|
+
*/
|
|
113
|
+
function buildUpdateNotice(info, instruction) {
|
|
114
|
+
return `Update available: amicus v${info.current} → v${info.latest}. `
|
|
115
|
+
+ `${instruction || upgradeInstruction()} Changelog: ${CHANGELOG_URL}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Once-per-process latch (spec D1). Flips ONLY on an actual append. */
|
|
119
|
+
let _noticeShown = false;
|
|
120
|
+
|
|
121
|
+
/** Test seam: re-arm the latch. */
|
|
122
|
+
function _resetLatchForTests() { _noticeShown = false; }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The seam the MCP registration wrapper routes EVERY result through: append
|
|
126
|
+
* the notice block to the first successful tool result of this process, then
|
|
127
|
+
* stay quiet. No-op on isError results, unknown update state, malformed
|
|
128
|
+
* results, or any internal failure — the original result always comes back.
|
|
129
|
+
* @param {{content?: Array, isError?: boolean}|null} result
|
|
130
|
+
* @param {{getUpdateInfo?: Function}} [deps]
|
|
131
|
+
*/
|
|
132
|
+
function maybeAppendUpdateNotice(result, deps = {}) {
|
|
133
|
+
try {
|
|
134
|
+
if (_noticeShown) { return result; }
|
|
135
|
+
if (!result || result.isError || !Array.isArray(result.content)) { return result; }
|
|
136
|
+
const getUpdateInfo = deps.getUpdateInfo || require('./updater').getUpdateInfo;
|
|
137
|
+
const info = getUpdateInfo();
|
|
138
|
+
if (!info || !info.hasUpdate) { return result; }
|
|
139
|
+
result.content.push({ type: 'text', text: buildUpdateNotice(info) });
|
|
140
|
+
_noticeShown = true;
|
|
141
|
+
return result;
|
|
142
|
+
} catch {
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The amicus_guide version-line suffix (NOT latched — the guide is the
|
|
149
|
+
* on-demand surface), or null when there is nothing to say.
|
|
150
|
+
* @param {{getUpdateInfo?: Function}} [deps] - plus upgradeInstruction seams
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
function guideUpdateLine(deps = {}) {
|
|
154
|
+
try {
|
|
155
|
+
const getUpdateInfo = deps.getUpdateInfo || require('./updater').getUpdateInfo;
|
|
156
|
+
const info = getUpdateInfo();
|
|
157
|
+
if (!info || !info.hasUpdate) { return null; }
|
|
158
|
+
return `**Update available: v${info.latest}** — ${upgradeInstruction(deps)}`;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
classifySelfInstall,
|
|
166
|
+
upgradeInstruction,
|
|
167
|
+
buildUpdateNotice,
|
|
168
|
+
maybeAppendUpdateNotice,
|
|
169
|
+
guideUpdateLine,
|
|
170
|
+
_resetLatchForTests,
|
|
171
|
+
};
|
|
@@ -70,7 +70,11 @@ function readPointer(project, runId) {
|
|
|
70
70
|
const id = String(runId).replace(/^council-/, '');
|
|
71
71
|
if (!RUN_ID_RE.test(id)) { return { runId: id, error: 'invalid runId' }; }
|
|
72
72
|
const ptr = runState.readPointer(project, id);
|
|
73
|
-
if (!ptr) {
|
|
73
|
+
if (!ptr) {
|
|
74
|
+
return { runId: id, error: 'pointer missing — run pointers live under the LAUNCH directory '
|
|
75
|
+
+ '(where `council run` was invoked), not --out-dir. If this run used --out-dir, point '
|
|
76
|
+
+ '--project at the launch directory instead.' };
|
|
77
|
+
}
|
|
74
78
|
return { runId: id, runDir: ptr.runDir };
|
|
75
79
|
}
|
|
76
80
|
|