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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +113 -0
  3. package/README.md +1 -1
  4. package/commands/council.md +1 -1
  5. package/docs/DISTRIBUTION.md +38 -11
  6. package/docs/ROADMAP.md +40 -8
  7. package/docs/publishing.md +1 -1
  8. package/docs/usage.md +1 -1
  9. package/package.json +3 -2
  10. package/schemas/council-run.schema.json +20 -0
  11. package/schemas/council-verdict.schema.json +20 -0
  12. package/schemas/doctor.schema.json +23 -1
  13. package/skills/second-opinion/MODEL-NOTES.md +182 -35
  14. package/src/cli-council-run-render.js +51 -0
  15. package/src/cli-handlers-council-run.js +45 -44
  16. package/src/cli-handlers-council.js +9 -3
  17. package/src/cli-handlers-doctor.js +16 -37
  18. package/src/cli-handlers-watch.js +1 -1
  19. package/src/cli.js +1 -1
  20. package/src/council/ledger.js +5 -1
  21. package/src/council/report-html.js +16 -1
  22. package/src/council/report.js +25 -1
  23. package/src/council/run-assemble.js +25 -7
  24. package/src/council/run-budget.js +14 -8
  25. package/src/council/run-chair.js +21 -4
  26. package/src/council/run-debate-stage.js +115 -0
  27. package/src/council/run-degrade.js +44 -0
  28. package/src/council/run-finalize.js +18 -3
  29. package/src/council/run-launch.js +4 -0
  30. package/src/council/run-retry-notes.js +74 -0
  31. package/src/council/run-retry.js +280 -0
  32. package/src/council/run-server.js +24 -7
  33. package/src/council/run-stage2.js +10 -2
  34. package/src/council/run-stages.js +59 -27
  35. package/src/council/run.js +39 -67
  36. package/src/council/verdict.js +81 -8
  37. package/src/mcp-council-bench.js +45 -0
  38. package/src/mcp-council-run.js +11 -28
  39. package/src/mcp-server.js +22 -3
  40. package/src/mcp-tools.js +13 -1
  41. package/src/utils/degrade.js +69 -0
  42. package/src/utils/doctor-degrade.js +51 -0
  43. package/src/utils/doctor-electron-mcp-check.js +64 -5
  44. package/src/utils/doctor-engine-check.js +14 -3
  45. package/src/utils/doctor-mcp-checks.js +10 -3
  46. package/src/utils/known-flags.js +2 -1
  47. package/src/utils/remediation-hints.js +20 -14
  48. package/src/utils/result-schema.js +6 -2
  49. package/src/utils/session-index-tmp-sweep.js +2 -1
  50. package/src/utils/update-notice.js +171 -0
  51. 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 { ...after, message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})` };
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 { ...after, message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})` };
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
- return { ...after, message: `${after.message}; self-heal incomplete: ${failed}` };
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 };
@@ -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
- * ⚠️ The first four are spawned by the MCP server onto its own CLI children
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 — it was likely skipped during install or quarantined by antivirus. '
45
- + 'Run "amicus doctor", reinstall with "npm i -g amicus", and allow-list opencode.exe in your antivirus.',
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). This is the convergence
62
- * target for the three "reinstall to fix Electron" hints — it provisions the
63
- * binary from cache (or downloads on demand) WITHOUT a global reinstall, so it
64
- * can't loop the way `npm install -g amicus` could when the rollback recurs.
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}>}} opts
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
- return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null };
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) { return { runId: id, error: 'pointer missing, unreadable, or invalid' }; }
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