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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +154 -0
- package/README.md +15 -2
- package/bin/amicus.js +10 -0
- package/docs/ROADMAP.md +38 -14
- package/docs/configuration.md +24 -0
- package/docs/council.md +62 -0
- package/docs/schemas.md +1 -0
- package/docs/usage.md +151 -1
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-panels.js +76 -18
- package/electron/workspace-ui/workspace-render.js +10 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +14 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +1 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-doctor.js +10 -0
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-template.js +53 -0
- package/src/cli.js +64 -3
- package/src/council/findings.js +4 -41
- package/src/council/presets-cli.js +23 -11
- package/src/council/run-stages.js +12 -9
- package/src/council/run-state.js +17 -0
- package/src/council/run.js +1 -1
- package/src/headless.js +18 -14
- package/src/mcp-council-run.js +110 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/electron-state.js +61 -0
- package/src/sidecar/fanout.js +21 -4
- package/src/sidecar/progress.js +34 -0
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +83 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/doctor-electron-mcp-check.js +150 -0
- package/src/utils/error-doc.js +5 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/workspace/artifact-guard.js +44 -6
- 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 };
|
package/src/utils/error-doc.js
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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-${
|
|
106
|
-
names.push(`revote-${
|
|
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
|
|