amicus 4.4.1 → 4.5.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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +154 -0
  3. package/README.md +15 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/ROADMAP.md +38 -14
  6. package/docs/configuration.md +24 -0
  7. package/docs/council.md +62 -0
  8. package/docs/schemas.md +1 -0
  9. package/docs/usage.md +151 -1
  10. package/electron/workspace-ui/workspace-app.js +39 -17
  11. package/electron/workspace-ui/workspace-panels.js +76 -18
  12. package/electron/workspace-ui/workspace-render.js +10 -0
  13. package/package.json +1 -1
  14. package/schemas/council-run-live.schema.json +1 -1
  15. package/schemas/council-run.schema.json +14 -0
  16. package/schemas/error.schema.json +1 -1
  17. package/schemas/event.schema.json +1 -1
  18. package/schemas/pack.schema.json +30 -0
  19. package/schemas/progress.schema.json +1 -1
  20. package/schemas/run-live.schema.json +1 -1
  21. package/schemas/run.schema.json +2 -1
  22. package/schemas/wave-live.schema.json +1 -1
  23. package/schemas/wave.schema.json +2 -1
  24. package/skills/second-opinion/SKILL.md +5 -0
  25. package/src/cli-handlers-council-run.js +51 -8
  26. package/src/cli-handlers-doctor.js +10 -0
  27. package/src/cli-handlers-pack.js +238 -0
  28. package/src/cli-handlers-run.js +36 -8
  29. package/src/cli-handlers-template.js +53 -0
  30. package/src/cli.js +64 -3
  31. package/src/council/findings.js +4 -41
  32. package/src/council/presets-cli.js +23 -11
  33. package/src/council/run-stages.js +12 -9
  34. package/src/council/run-state.js +17 -0
  35. package/src/council/run.js +1 -1
  36. package/src/headless.js +18 -14
  37. package/src/mcp-council-run.js +110 -4
  38. package/src/mcp-server.js +203 -7
  39. package/src/mcp-tools.js +15 -5
  40. package/src/pack/pack-cli.js +38 -0
  41. package/src/pack/pack-forward.js +96 -0
  42. package/src/pack/pack-resolve.js +297 -0
  43. package/src/pack/pack-store.js +130 -0
  44. package/src/pack/pack-validate.js +113 -0
  45. package/src/sidecar/electron-state.js +61 -0
  46. package/src/sidecar/fanout.js +21 -4
  47. package/src/sidecar/progress.js +34 -0
  48. package/src/sidecar/start.js +5 -4
  49. package/src/sidecar/workspace-auto-open.js +83 -0
  50. package/src/sidecar/workspace-window.js +46 -1
  51. package/src/template/apply.js +88 -0
  52. package/src/template/render.js +86 -0
  53. package/src/template/store.js +106 -0
  54. package/src/utils/config.js +65 -25
  55. package/src/utils/doctor-electron-mcp-check.js +150 -0
  56. package/src/utils/error-doc.js +5 -0
  57. package/src/utils/result-schema-rebuild.js +1 -0
  58. package/src/utils/result-schema.js +8 -2
  59. package/src/workspace/artifact-guard.js +44 -6
  60. package/src/workspace/run-detail.js +6 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @module utils/doctor-electron-mcp-check
3
+ * The `electron-mcp` doctor check ("Electron (MCP launch path)"), split out of
4
+ * src/cli-handlers-doctor.js to keep that file under the 300-line gate
5
+ * (mirrors doctor-engine-check.js, which did the same for the engine — #76 is
6
+ * the electron-flavored recurrence of that check's bug report #1).
7
+ *
8
+ * The existing `electron` check verifies Electron in the RUNNING install. This
9
+ * one verifies the copies the MCP actually launches from — the npx-cache
10
+ * installs `npx -y amicus@latest mcp` resolves to — so a green doctor can no
11
+ * longer hide an npx copy whose `ui: true` will fail with electron-absent.
12
+ *
13
+ * Severity is WARN at worst (never error): a broken Electron only costs the
14
+ * GUI; headless councils still work — same tier the running-copy check uses.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const HINTS = require('./remediation-hints');
20
+
21
+ const plural = (n, one, many) => (n === 1 ? one : many);
22
+
23
+ /** One-line detail for a broken copy, distinguishing the two states (#76). */
24
+ const describeBroken = (i) => (i.state === 'binary-missing'
25
+ ? `${i.pkgDir} (binary missing; electron dir: ${i.electronDir})`
26
+ : `${i.pkgDir} (not installed)`);
27
+
28
+ /**
29
+ * Enumerate the amicus installs that could serve the MCP and probe Electron in
30
+ * each via the dual-root resolver (#69 lesson: npx HOISTS electron to a
31
+ * sibling; a global install nests it).
32
+ * @param {object} [deps] engine-install-scan seams plus {readAmicusMcpConfig}
33
+ * @returns {{installs:Array<{kind,pkgDir,electronDir,state}>, mcpLaunch:string}}
34
+ */
35
+ function scanElectronInstalls(deps = {}) {
36
+ const fs = deps.fs || require('fs');
37
+ const platform = deps.platform || process.platform;
38
+ const readAmicusMcpConfig = deps.readAmicusMcpConfig
39
+ || (() => require('./mcp-discovery').readAmicusMcpConfig());
40
+ const { listAmicusInstalls, classifyLaunch } = require('./engine-install-scan');
41
+ const { electronDirFor, probeElectronState } = require('../sidecar/electron-state');
42
+
43
+ const installs = listAmicusInstalls(deps).map((i) => {
44
+ const probe = probeElectronState({ electronDir: electronDirFor(i.pkgDir, { fs }), fs, platform });
45
+ return { ...i, electronDir: probe.electronDir, state: probe.state };
46
+ });
47
+ let config = null;
48
+ try { config = readAmicusMcpConfig(); } catch { /* unreadable config */ }
49
+ return { installs, mcpLaunch: classifyLaunch(config) };
50
+ }
51
+
52
+ /**
53
+ * @param {{scanElectronInstalls: () => {installs:Array, mcpLaunch:string}}} d
54
+ * @returns {{id,name,status,message,hint}}
55
+ */
56
+ function evaluateElectronInstalls(d) {
57
+ const id = 'electron-mcp';
58
+ const name = 'Electron (MCP launch path)';
59
+ const { installs, mcpLaunch } = d.scanElectronInstalls();
60
+
61
+ if (mcpLaunch === 'none') {
62
+ return { id, name, status: 'ok', message: 'no amicus MCP registered — not checked', hint: null };
63
+ }
64
+ if (mcpLaunch === 'path') {
65
+ return {
66
+ id, name, status: 'ok',
67
+ message: 'MCP launches from a fixed path — covered by the Electron (interactive GUI) check', hint: null,
68
+ };
69
+ }
70
+
71
+ // 'npx' (and the 'unknown' fallback): verify the npx-cache copies, the ones
72
+ // whose optional-dependency electron install can silently half-complete.
73
+ const npxCopies = installs.filter((i) => i.kind === 'npx');
74
+ if (npxCopies.length === 0) {
75
+ return {
76
+ id, name, status: 'warn',
77
+ message: 'MCP launches via npx; no cached copy to inspect yet — run one council, then re-run doctor',
78
+ hint: null,
79
+ };
80
+ }
81
+
82
+ const broken = npxCopies.filter((i) => i.state !== 'ok');
83
+ if (broken.length === 0) {
84
+ return {
85
+ id, name, status: 'ok',
86
+ message: `electron present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
87
+ hint: null,
88
+ };
89
+ }
90
+
91
+ // `doctor --fix` can heal binary-missing (repairElectron); a never-installed
92
+ // package it cannot — keep the hint honest about which state is fixable.
93
+ const anyRepairable = broken.some((i) => i.state === 'binary-missing');
94
+ if (npxCopies.length === 1) {
95
+ const [only] = broken;
96
+ const message = only.state === 'binary-missing'
97
+ ? `electron binary missing in the npx-cache copy the MCP launches: ${only.pkgDir} (electron dir: ${only.electronDir})`
98
+ : `electron not installed in the npx-cache copy the MCP launches: ${only.pkgDir} — GUI auto-open unavailable; headless still works`;
99
+ return { id, name, status: 'warn', message, hint: anyRepairable ? HINTS.doctorFix : null };
100
+ }
101
+ const detail = broken.map(describeBroken).join('; ');
102
+ return {
103
+ id, name, status: 'warn',
104
+ message: `electron unavailable in ${broken.length}/${npxCopies.length} npx-cache copies: ${detail}`,
105
+ hint: anyRepairable ? HINTS.doctorFix : null,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Fix-aware wrapper. When d.fix and the scan shows binary-missing npx copies,
111
+ * heal each in place via d.repairElectron({electronDir}) — the package dir is
112
+ * already on disk, so repairElectron can read its version and provision the
113
+ * exe — then re-report from a fresh scan. package-missing copies are never
114
+ * repaired (no package to repair into). Mirrors evaluateEngineMcp.
115
+ * @param {object} d doctor deps (scanElectronInstalls, fix?, repairElectron?, fixTimeoutMs?)
116
+ * @returns {Promise<{id,name,status,message,hint}>}
117
+ */
118
+ async function evaluateElectronMcp(d) {
119
+ const verdict = evaluateElectronInstalls(d);
120
+ if (!d.fix || verdict.status === 'ok') { return verdict; }
121
+
122
+ const { installs } = d.scanElectronInstalls();
123
+ const repairable = installs.filter((i) => i.kind === 'npx' && i.state === 'binary-missing');
124
+ if (repairable.length === 0) { return verdict; }
125
+
126
+ const results = [];
127
+ for (const b of repairable) {
128
+ let r;
129
+ try {
130
+ r = await d.repairElectron({
131
+ electronDir: b.electronDir,
132
+ ...(d.fixTimeoutMs ? { timeoutMs: d.fixTimeoutMs } : {}),
133
+ });
134
+ } catch (e) { r = { repaired: false, reason: e && e.message }; }
135
+ results.push({ electronDir: b.electronDir, ...r });
136
+ }
137
+
138
+ const after = evaluateElectronInstalls(d); // fresh scan reflects the repairs
139
+ if (after.status === 'ok') {
140
+ const n = results.length;
141
+ return { ...after, message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})` };
142
+ }
143
+ const failed = results.filter((r) => !r.repaired)
144
+ .map((r) => `${r.electronDir}${r.reason ? ` — ${r.reason}` : ''}`).join('; ');
145
+ return failed
146
+ ? { ...after, message: `${after.message}; self-heal incomplete: ${failed}` }
147
+ : after;
148
+ }
149
+
150
+ module.exports = { scanElectronInstalls, evaluateElectronInstalls, evaluateElectronMcp };
@@ -24,6 +24,11 @@ const ERROR_CODES = Object.freeze({
24
24
  COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
25
25
  // council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
26
26
  COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
27
+ TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', // --template name/path unresolvable at run time (v4.5 F9)
28
+ TEMPLATE_RENDER: 'TEMPLATE_RENDER', // strict render rule violated: unknown var, slot/data mismatch (v4.5 F9)
29
+ PACK_NOT_FOUND: 'PACK_NOT_FOUND', // --pack name not in packs dir / path unreadable (v4.5 B7/F5)
30
+ PACK_INVALID: 'PACK_INVALID', // pack schema/structural/seat validation failure (v4.5 B7/F5)
31
+ PACK_KIND_MISMATCH: 'PACK_KIND_MISMATCH', // e.g. a council pack passed to fanout (v4.5 B7/F5)
27
32
  });
28
33
 
29
34
  /**
@@ -90,6 +90,7 @@ function buildWaveResultFromSession(project, waveId) {
90
90
  waveId,
91
91
  legs,
92
92
  promptMeta: meta.promptMeta || null,
93
+ ...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
93
94
  createdAt: meta.createdAt || null,
94
95
  completedAt: meta.completedAt || null,
95
96
  });
@@ -44,7 +44,9 @@ function durationBetween(createdAt, completedAt) {
44
44
  * @param {string|null} [opts.modelInput] - What the caller typed (alias), if known
45
45
  * @param {string|null} [opts.sessionDir]
46
46
  * @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
47
- * @returns {object} run document
47
+ * @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
48
+ * metadata.pack was recorded (solo session launched via --pack), sourced straight off
49
+ * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
48
50
  */
49
51
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
50
52
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -68,6 +70,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
68
70
  sessionDir,
69
71
  opencodeSessionId: metadata.opencodeSessionId || null,
70
72
  usage: usage !== null ? usage : (metadata.usage || null),
73
+ ...(metadata.pack ? { pack: metadata.pack } : {}),
71
74
  };
72
75
  }
73
76
 
@@ -122,9 +125,11 @@ function waveExitCode(waveStatus) {
122
125
  * @param {string|null} [opts.completedAt]
123
126
  * @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
124
127
  * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
128
+ * @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
129
+ * additive — present only when the wave was launched via --pack (absent, never null, otherwise).
125
130
  * @returns {object} wave document
126
131
  */
127
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
132
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
128
133
  const { sumWaveUsage } = require('./pricing');
129
134
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
130
135
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -151,6 +156,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
151
156
  durationMs,
152
157
  usage: sumWaveUsage(legs),
153
158
  notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
159
+ ...(pack ? { pack } : {}),
154
160
  };
155
161
  }
156
162
 
@@ -96,14 +96,37 @@ function artifactAllowlist(run) {
96
96
  }
97
97
  }
98
98
 
99
+ // ⚠️ Task 18 (RN-1): the collision above is a real run-integrity defect — the run directory
100
+ // physically holds ONE file where two models' artifacts should be, and no renderer trick can
101
+ // recover both. What the renderer CAN stop doing is showing model A's prose under model B's
102
+ // name. Deterministic disambiguation: per colliding sanitized name, sort the RAW models
103
+ // (sorting, not insertion order, is what keeps this reproducible across processes/runs); the
104
+ // first (sorted) keeps the bare sanitized name, the rest get `~2`, `~3`, ... The suffixed
105
+ // names deliberately do not exist on disk — the presence manifest (run-detail.js, via
106
+ // fs.statSync over this same allowlist) marks them absent, so the renderer shows the honest
107
+ // "not written yet" empty state for every model but the first, instead of cross-matching.
108
+ const nameFor = new Map(); // raw model -> its (possibly suffixed) sanitized name
99
109
  for (const m of uniqueModels) {
100
- names.push(`review-${sanitizeName(m)}.md`);
101
- names.push(`judge-${sanitizeName(m)}.md`);
102
- // rebuttal-/revote- are keyed on the same BENCH ALIAS through the same sanitizeName
103
- // (materializeDebate is called with `d.raiser` / the revote leg's model — both aliases).
110
+ let s = sanitizeName(m);
111
+ const collision = collisionModels.get(s);
112
+ if (collision) {
113
+ const sortedRaw = [...collision].sort();
114
+ const index = sortedRaw.indexOf(m);
115
+ if (index > 0) { s = `${s}~${index + 1}`; }
116
+ }
117
+ nameFor.set(m, s);
118
+ }
119
+
120
+ for (const m of uniqueModels) {
121
+ const s = nameFor.get(m);
122
+ names.push(`review-${s}.md`);
123
+ names.push(`judge-${s}.md`);
124
+ // rebuttal-/revote- are keyed on the same BENCH ALIAS through the same (now possibly
125
+ // suffixed) name — materializeDebate is called with `d.raiser` / the revote leg's model
126
+ // (both aliases), so a colliding pair's debate artifacts are disambiguated the same way.
104
127
  if (debated) {
105
- names.push(`rebuttal-${sanitizeName(m)}.md`);
106
- names.push(`revote-${sanitizeName(m)}.md`);
128
+ names.push(`rebuttal-${s}.md`);
129
+ names.push(`revote-${s}.md`);
107
130
  }
108
131
  }
109
132
  // `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
@@ -115,6 +138,21 @@ function artifactAllowlist(run) {
115
138
  sanitized, models: [...models],
116
139
  }));
117
140
  }
141
+ // Consumed by workspace-panels.js (wireLazyPanels' file lists + drillIntoJudge's artifact
142
+ // lookup), which prefers this map over re-deriving names via sanitizeName(model) directly —
143
+ // that re-derivation is exactly what would ignore the suffixing above and misattribute prose.
144
+ // ⚠️ Fix-wave (review finding 1) residual limit this map cannot close: the BARE (unsuffixed)
145
+ // name is still exactly ONE physical file on disk, and its actual bytes belong to whichever
146
+ // colliding model's writer ran LAST — no map can recover which one that was. The guarantee
147
+ // delivered here is narrower than "attribution is fully sound": at most the sorted-first
148
+ // model can still be misattributed under the bare name; artifactCollisions (the run-integrity
149
+ // banner rendered by workspace-app.js's renderBanners) is what covers that residual case.
150
+ list.artifactsByModel = Object.fromEntries(
151
+ [...nameFor].map(([m, s]) => [m, {
152
+ review: `review-${s}.md`, judge: `judge-${s}.md`,
153
+ rebuttal: `rebuttal-${s}.md`, revote: `revote-${s}.md`,
154
+ }]),
155
+ );
118
156
  return list;
119
157
  }
120
158
 
@@ -209,6 +209,12 @@ function getRunDetail(project, runId) {
209
209
  // would otherwise silently misattribute prose. Surfaced here (rather than only inside
210
210
  // the low-level allowlist helper) so the renderer can warn the user directly.
211
211
  artifactCollisions: artifactNames.collisions || [],
212
+ // ⚠️ Task 18 (RN-1): the de-collision map itself (raw model -> {review, judge} filename),
213
+ // built once in artifactAllowlist alongside `collisions` above. `|| null` (absent, not an
214
+ // empty object) so workspace-panels.js can tell "no map at all" (older detail payloads —
215
+ // pre-v4.5 runs, live-doc consumers not yet updated) apart from a real map, and fall back
216
+ // to its legacy sanitizeName(model) computation only in the former case.
217
+ artifactsByModel: artifactNames.artifactsByModel || null,
212
218
  };
213
219
  }
214
220