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,297 @@
1
+ // src/pack/pack-resolve.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module pack/pack-resolve
6
+ * B7/F5 (v4.5), Task 11. The pack→args merge engine: the single place that
7
+ * turns a resolved, validated pack into filled-in CLI arg values. Precedence
8
+ * is explicit flag > pack > config default > built-in, achieved by filling
9
+ * `args` ONLY for keys the caller did not type (`explicit`, from parseArgs'
10
+ * `__explicit` — Task 10) — everything else (config defaults, built-in
11
+ * fallbacks, cross-field validation) is left to the handler that calls this,
12
+ * unchanged, so v4.0 pre-flight validation runs on the merged, effective
13
+ * values automatically (spec §5.4).
14
+ *
15
+ * Deliberately does NOT call applyTemplate: a pack's `briefing.template` is
16
+ * just another knob that fills `args.template` (a ref, not rendered text).
17
+ * Rendering stays a single-application-point concern of the CLI handlers
18
+ * (Task 12/13), so a pack-filled --template and a typed --template take
19
+ * exactly the same downstream code path.
20
+ */
21
+
22
+ const { readPack } = require('./pack-store');
23
+ const { validatePack } = require('./pack-validate');
24
+ const { ERROR_CODES } = require('../utils/error-doc');
25
+
26
+ /** `options.*` knobs shared by every kind that defines them (council/fanout/solo).
27
+ * `agent` is deliberately NOT in this table — see the dedicated fill below. */
28
+ const COMMON_OPTION_KNOBS = [
29
+ ['timeout', 'timeout'], ['maxCost', 'max-cost'], ['gateway', 'gateway'],
30
+ ['thinking', 'thinking'], ['summaryLength', 'summary-length'],
31
+ ];
32
+ /** `options.*` knobs shared by fanout + solo only (v4.5 F4 context controls). */
33
+ const CONTEXT_OPTION_KNOBS = [
34
+ ['noContext', 'no-context'], ['contextTurns', 'context-turns'], ['contextMaxTokens', 'context-max-tokens'],
35
+ ];
36
+ /** Concrete command name per pack kind, for the KIND_MISMATCH message (never "this command"). */
37
+ const COMMAND_NAME_BY_KIND = { council: 'council run', fanout: 'fanout', solo: 'start' };
38
+ /** MCP tool name per pack kind, for the Finding-2 (Task 15 review) orphan-knob notice below. */
39
+ const MCP_TOOL_NAME_BY_KIND = { council: 'amicus_council_run', fanout: 'amicus_fanout', solo: 'amicus_start' };
40
+
41
+ /**
42
+ * council/fanout only: `bench` fills `args.council` (by-name) or `args.models`
43
+ * (csv), unless the caller already typed one of the two — then it is never
44
+ * silently dropped, it becomes a notice instead.
45
+ * @returns {string|null} a notice string, or null when the bench was applied cleanly.
46
+ */
47
+ function resolveBenchKnob(pack, args, explicit) {
48
+ const bench = pack.bench;
49
+ if (bench === undefined || bench === null) { return null; }
50
+ const modelsExplicit = explicit.has('models');
51
+ const councilExplicit = explicit.has('council');
52
+ if (!modelsExplicit && !councilExplicit) {
53
+ if (typeof bench === 'string') { args.council = bench; }
54
+ else if (Array.isArray(bench)) { args.models = bench.join(','); }
55
+ return null;
56
+ }
57
+ const flag = modelsExplicit ? '--models' : '--council';
58
+ return `Notice: ${flag} overrides the bench from pack '${pack.name}'`;
59
+ }
60
+
61
+ /**
62
+ * @param {{packRef: string, expectedKind: 'council'|'fanout'|'solo', args: object,
63
+ * explicit: Set<string>, useJson?: boolean}} opts `useJson` is accepted for
64
+ * call-site symmetry with sibling resolvers (e.g. applyTemplate) but unused
65
+ * here: this module never writes to stdout/stderr itself — notices are
66
+ * returned as plain strings and the caller alone decides how to print them.
67
+ * @returns {{packRecord: {name, version, hash, source}, notices: string[]}
68
+ * | {error: {code, message, hint}}}
69
+ */
70
+ function applyPackToArgs({ packRef, expectedKind, args, explicit }) {
71
+ const rp = readPack(packRef);
72
+ if (rp.error) {
73
+ return { error: { code: ERROR_CODES.PACK_NOT_FOUND, message: rp.error, hint: 'amicus pack list' } };
74
+ }
75
+ const { pack, source, hash } = rp;
76
+
77
+ if (pack.kind !== expectedKind) {
78
+ return {
79
+ error: {
80
+ code: ERROR_CODES.PACK_KIND_MISMATCH,
81
+ message: `Error: pack '${pack.name}' is kind '${pack.kind}' — ${COMMAND_NAME_BY_KIND[expectedKind] || 'this command'} accepts kind '${expectedKind}'; make two packs if you want both shapes`,
82
+ hint: null,
83
+ },
84
+ };
85
+ }
86
+
87
+ const validation = validatePack(pack, { mode: 'run' });
88
+ if (!validation.ok) {
89
+ return {
90
+ error: {
91
+ code: ERROR_CODES.PACK_INVALID,
92
+ message: `Error: pack '${pack.name}' failed validation: ${validation.errors.join('; ')}`,
93
+ hint: null,
94
+ },
95
+ };
96
+ }
97
+
98
+ const opts = pack.options || {};
99
+ const notices = [];
100
+ const fill = (argKey, value) => {
101
+ if (!explicit.has(argKey) && value !== undefined && value !== null) {
102
+ args[argKey] = value;
103
+ }
104
+ };
105
+
106
+ if (pack.kind === 'council') {
107
+ fill('chair', pack.chair);
108
+ }
109
+ if (pack.kind === 'solo') {
110
+ fill('model', pack.model);
111
+ fill('no-ui', opts.noUi);
112
+ }
113
+
114
+ for (const [optKey, argKey] of COMMON_OPTION_KNOBS) { fill(argKey, opts[optKey]); }
115
+
116
+ // v4.5 final-review F4: cli-handlers-run.js normalizes a legacy --mode flag
117
+ // into args.agent AFTER this merge runs (`args.agent = args.agent ||
118
+ // args.mode`, handleStart/handleFanout only — council has no --mode flag).
119
+ // A typed --mode with no --agent lands 'mode' in `explicit` but not 'agent',
120
+ // so the generic fill() above would silently overwrite a pack's
121
+ // options.agent onto args.agent before that fallback ever sees args.mode —
122
+ // the user's typed value loses to the pack, invisibly. Count --mode as
123
+ // agent-explicit too (same shape as the debate/no-debate negation check
124
+ // below, which also treats an alternate typed form as full explicitness).
125
+ const agentExplicit = explicit.has('agent') || explicit.has('mode');
126
+ if (!agentExplicit && opts.agent !== undefined && opts.agent !== null) { args.agent = opts.agent; }
127
+
128
+ if (pack.kind === 'council') {
129
+ // Task 10 ruling: negations record only the literal typed key, so a
130
+ // negatable boolean's "was this explicit" check must test both forms.
131
+ const debateExplicit = explicit.has('debate') || explicit.has('no-debate');
132
+ if (!debateExplicit && opts.debate !== undefined && opts.debate !== null) {
133
+ args.debate = opts.debate;
134
+ }
135
+ // 2026-07-28 ruling: skip filling pack critic when --lenses is explicit —
136
+ // it would trip the handler's critic/lenses mutual-exclusion pre-flight.
137
+ if (!explicit.has('lenses')) { fill('critic', pack.critic); }
138
+ // 2026-07-28 ruling: skip filling pack lenses when --critic is explicit —
139
+ // same mutual-exclusion pre-flight, mirrored direction.
140
+ if (!explicit.has('critic')) {
141
+ fill('lenses', Array.isArray(pack.lenses) ? pack.lenses.join(',') : pack.lenses);
142
+ }
143
+ }
144
+
145
+ if (pack.kind === 'fanout' || pack.kind === 'solo') {
146
+ for (const [optKey, argKey] of CONTEXT_OPTION_KNOBS) { fill(argKey, opts[optKey]); }
147
+ }
148
+
149
+ if (pack.kind === 'council' || pack.kind === 'fanout') {
150
+ const notice = resolveBenchKnob(pack, args, explicit);
151
+ if (notice) { notices.push(notice); }
152
+ }
153
+
154
+ fill('template', pack.briefing && pack.briefing.template);
155
+
156
+ return {
157
+ packRecord: { name: pack.name, version: pack.version, hash, source },
158
+ notices,
159
+ };
160
+ }
161
+
162
+ /** argKeys whose CLI-side value is a comma-joined string but whose MCP-side
163
+ * value is an array (`models`, `lenses`) — the two shapes multi-value knobs
164
+ * have always used on their respective sides, round-tripped by the bridge
165
+ * below so `applyPackToArgs`'s tables never need to know about MCP shapes. */
166
+ const CSV_ARG_KEYS = new Set(['models', 'lenses']);
167
+
168
+ /** Reverse of the knob tables above (argKey -> pack's own camelCase option
169
+ * key), used ONLY in notice text (v4.5 decision 1b, T15-m10) — naming a
170
+ * pack's own key is less confusing than the CLI's kebab-case arg-key. Most
171
+ * bespoke (non-table) knobs — chair/critic/lenses/agent/debate — share their
172
+ * argKey/option-key spelling, so the plain-argKey fallback is correct for
173
+ * them; `no-ui` does NOT (pack-side `noUi`) and is listed explicitly. `bench`
174
+ * fills EITHER args.council or args.models (never an argKey of its own) —
175
+ * accepted because both always have a real MCP destination, so a
176
+ * bench-derived value never actually reaches the notice path. */
177
+ const ARG_KEY_TO_OPT_KEY = Object.assign(
178
+ Object.fromEntries([...COMMON_OPTION_KNOBS, ...CONTEXT_OPTION_KNOBS].map(([optKey, argKey]) => [argKey, optKey])),
179
+ { 'no-ui': 'noUi' },
180
+ );
181
+
182
+ /** argKeys forwarded instead of notice'd when a tool's paramMap has no
183
+ * destination for them (v4.5 decision 1): a pack's maxCost/template must
184
+ * still apply on `amicus_fanout`/`amicus_start` for CLI parity, even with no
185
+ * schema param for either. `amicus_council_run` already has real destinations
186
+ * for both (COUNCIL_PACK_PARAM_MAP), so it never reaches this path. Context
187
+ * knobs are deliberately excluded — ruled notice-only, out of scope. */
188
+ const FORWARDABLE_ARG_KEYS = new Set(['max-cost', 'template']);
189
+
190
+ function toArgValue(argKey, value) {
191
+ return (CSV_ARG_KEYS.has(argKey) && Array.isArray(value)) ? value.join(',') : value;
192
+ }
193
+ function fromArgValue(argKey, value) {
194
+ return (CSV_ARG_KEYS.has(argKey) && typeof value === 'string')
195
+ ? value.split(',').map((s) => s.trim()).filter(Boolean)
196
+ : value;
197
+ }
198
+
199
+ /**
200
+ * MCP entry point (Task 15, B7/F5): the same merge as `applyPackToArgs`, for a
201
+ * caller whose knobs live on an `input` object with MCP-shaped key names
202
+ * (camelCase, some renamed/inverted vs. the CLI's kebab-case arg keys) rather
203
+ * than parsed CLI `args`. Builds an args-shaped bridge object via `paramMap`,
204
+ * reuses `applyPackToArgs` UNCHANGED (same knob tables, same precedence, same
205
+ * notices), then copies pack-filled values back onto `input` under their
206
+ * MCP-facing names. An mcpKey already present on `input` is never touched
207
+ * (explicit wins) — `explicit` is built from the caller's OWN `Object.keys(input)`,
208
+ * mapped through `paramMap`, mirroring `args.__explicit` on the CLI side.
209
+ *
210
+ * Deliberately contains NO pack-domain knowledge (no knob names, no bench/chair/
211
+ * timeout logic) beyond the key-renaming in `paramMap` — that stays entirely in
212
+ * `applyPackToArgs`'s tables above. Re-deriving "is this pack-filled" outside
213
+ * pack-resolve is exactly the mistake a prior review caught (progress.md,
214
+ * Task-12 entry: "packSuffix is council-local; if fanout/solo need attribution,
215
+ * centralize 'pack-filled' in pack-resolve rather than re-deriving").
216
+ *
217
+ * Fix wave 2 (Task 15 review, Finding 2): a pack knob `applyPackToArgs` fills
218
+ * but `paramMap` has no MCP destination for (e.g. fanout's `contextTurns`/
219
+ * `contextMaxTokens`) would otherwise be a silent dead-fill — written into the
220
+ * local `args` bridge, then dropped on the floor because the write-back loop
221
+ * below only ever visits `paramMap` entries. Made loud instead: every `args`
222
+ * key with no `paramMap` destination becomes one notice, naming the pack, the
223
+ * knob (by the pack's own camelCase option key — v4.5 HOLD-gate decision 1b,
224
+ * T15-m10), and the MCP tool.
225
+ *
226
+ * v4.5 decision 1: two otherwise-orphaned knobs — maxCost/template — are NOT
227
+ * turned into a notice on `amicus_fanout`/`amicus_start` (no schema param for
228
+ * either); CLI parity requires them to still apply, so their pack-filled
229
+ * values come back on `forward` instead, for the caller (mcp-server.js) to
230
+ * apply itself: forwarded to a spawned child's argv as plain flags, or (the
231
+ * in-process shared-server path) via the same budget-gate/template-render
232
+ * code the CLI uses. `amicus_council_run` already has real destinations for
233
+ * both, so `forward` is always `{}` there.
234
+ * @param {{packRef: string, expectedKind: 'council'|'fanout'|'solo',
235
+ * input: object, paramMap: Object<string, string|{argKey: string, invert: true}>}} opts
236
+ * `paramMap` maps each MCP input key a tool exposes to the CLI arg-key name
237
+ * the same knob uses in the tables above (e.g. council's `timeoutMinutes` →
238
+ * `'timeout'`, plain string = same-polarity rename/identity). An
239
+ * `{argKey, invert: true}` entry flips a boolean both ways (fanout/solo's
240
+ * `includeContext` vs. the CLI/pack-side `no-context` polarity). An MCP key
241
+ * with no pack-fillable knob (e.g. `briefingFile`, `project`) is simply
242
+ * omitted from `paramMap` and left untouched by this function.
243
+ * @returns {{packRecord: {name, version, hash, source}, notices: string[],
244
+ * forward: {maxCost?: number, template?: string}} | {error: {code, message, hint}}}
245
+ */
246
+ function applyPackToMcpInput({ packRef, expectedKind, input, paramMap }) {
247
+ const args = {};
248
+ const explicit = new Set();
249
+ for (const mcpKey of Object.keys(input)) {
250
+ const entry = paramMap[mcpKey];
251
+ if (!entry) { continue; }
252
+ const argKey = typeof entry === 'string' ? entry : entry.argKey;
253
+ const invert = typeof entry === 'object' && !!entry.invert;
254
+ args[argKey] = invert ? !input[mcpKey] : toArgValue(argKey, input[mcpKey]);
255
+ explicit.add(argKey);
256
+ }
257
+
258
+ const pr = applyPackToArgs({ packRef, expectedKind, args, explicit });
259
+ if (pr.error) { return { error: pr.error }; }
260
+
261
+ // Finding 2: diff every key applyPackToArgs touched (explicit bridge seeds +
262
+ // pack fills) against paramMap's own destination argKeys. The bridging loop
263
+ // above only ever seeds `args` from a paramMap destination, so anything left
264
+ // over here was added by a pack fill, never a caller value — always a
265
+ // genuine dead-fill, never a false positive on an explicit param.
266
+ const destArgKeys = new Set(
267
+ Object.values(paramMap).map((entry) => (typeof entry === 'string' ? entry : entry.argKey))
268
+ );
269
+ const notices = [...pr.notices];
270
+ const toolName = MCP_TOOL_NAME_BY_KIND[expectedKind] || `amicus (${expectedKind})`;
271
+ // v4.5 HOLD-gate decision 1: maxCost/template have no MCP schema destination
272
+ // on fanout/start, but must still apply (CLI parity) — collected here for the
273
+ // caller to apply itself, instead of turned into an ignore-notice. Council
274
+ // already has real destinations for both (destArgKeys.has(argKey) is true
275
+ // there), so this loop never adds to `forward` for council calls.
276
+ const forward = {};
277
+ for (const argKey of Object.keys(args)) {
278
+ if (destArgKeys.has(argKey)) { continue; }
279
+ if (FORWARDABLE_ARG_KEYS.has(argKey)) {
280
+ forward[ARG_KEY_TO_OPT_KEY[argKey] || argKey] = args[argKey];
281
+ continue;
282
+ }
283
+ const optKey = ARG_KEY_TO_OPT_KEY[argKey] || argKey;
284
+ notices.push(`Notice: pack '${pr.packRecord.name}' sets ${optKey}, which ${toolName} does not support over MCP — ignored.`);
285
+ }
286
+
287
+ for (const [mcpKey, entry] of Object.entries(paramMap)) {
288
+ const argKey = typeof entry === 'string' ? entry : entry.argKey;
289
+ if (explicit.has(argKey) || !(argKey in args)) { continue; }
290
+ const invert = typeof entry === 'object' && !!entry.invert;
291
+ input[mcpKey] = invert ? !args[argKey] : fromArgValue(argKey, args[argKey]);
292
+ }
293
+
294
+ return { packRecord: pr.packRecord, notices, forward };
295
+ }
296
+
297
+ module.exports = { applyPackToArgs, applyPackToMcpInput };
@@ -0,0 +1,130 @@
1
+ // src/pack/pack-store.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module pack/pack-store
6
+ * B7/F5 (v4.5): packs are ONE JSON file per pack in <configDir>/packs/ (spec
7
+ * carried decision 2 — shareability wins: send or commit the file). The
8
+ * CONTENT HASH is the truth anchor: sha256 over the canonical form (recursively
9
+ * sorted keys), first 12 hex chars, recorded on every run — a hand-edited pack
10
+ * whose version wasn't bumped still gets a distinct recorded hash.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+ const { writeFileAtomic } = require('../utils/atomic-write');
17
+
18
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
19
+
20
+ function _getConfigDir() { return require('../utils/config').getConfigDir(); }
21
+
22
+ /** @returns {string} the packs directory (peer of templates/) */
23
+ function packsDir() { return path.join(_getConfigDir(), 'packs'); }
24
+
25
+ function sortKeysDeep(v) {
26
+ if (Array.isArray(v)) { return v.map(sortKeysDeep); }
27
+ if (v && typeof v === 'object') {
28
+ const out = {};
29
+ for (const k of Object.keys(v).sort()) { out[k] = sortKeysDeep(v[k]); }
30
+ return out;
31
+ }
32
+ return v;
33
+ }
34
+
35
+ /** sha256 of the canonical (sorted-keys) JSON form, first 12 hex chars. */
36
+ function canonicalHash(pack) {
37
+ return crypto.createHash('sha256')
38
+ .update(JSON.stringify(sortKeysDeep(pack)), 'utf-8').digest('hex').slice(0, 12);
39
+ }
40
+
41
+ /** @returns {{kind:'path',path}|{kind:'name',name}|{error}} */
42
+ function resolvePackRef(ref) {
43
+ const v = String(ref);
44
+ if (v.endsWith('.json') || v.includes('/') || v.includes(path.sep)) {
45
+ return { kind: 'path', path: path.resolve(v) };
46
+ }
47
+ if (!NAME_RE.test(v)) {
48
+ return { error: `Error: invalid pack name '${v}' — pack names are 1-64 chars of [a-z0-9._-] starting alphanumeric` };
49
+ }
50
+ return { kind: 'name', name: v };
51
+ }
52
+
53
+ function stripBom(text) { return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; }
54
+
55
+ /** @returns {{pack, path, source:'dir'|'path', hash}|{error}} */
56
+ function readPack(ref) {
57
+ const r = resolvePackRef(ref);
58
+ if (r.error) { return r; }
59
+ const file = r.kind === 'path' ? r.path : path.join(packsDir(), `${r.name}.json`);
60
+ let raw;
61
+ try { raw = stripBom(fs.readFileSync(file, 'utf-8')); }
62
+ catch (err) {
63
+ if (r.kind === 'name') { return { error: `Error: Pack '${r.name}' not found in ${packsDir()}` }; }
64
+ return { error: `Error: cannot read pack ${ref}: ${err.message}` };
65
+ }
66
+ let pack;
67
+ try { pack = JSON.parse(raw); }
68
+ catch (err) { return { error: `Error: pack ${file} is not valid JSON: ${err.message}` }; }
69
+ if (r.kind === 'name' && pack && pack.name !== r.name) {
70
+ return { error: `Error: pack file ${file} declares name '${pack && pack.name}' which does not match its filename — rename one of them` };
71
+ }
72
+ return { pack, path: file, source: r.kind === 'name' ? 'dir' : 'path', hash: canonicalHash(pack) };
73
+ }
74
+
75
+ function bumpPatch(version) {
76
+ const m = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(String(version));
77
+ if (!m) { return '0.0.1'; }
78
+ return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
79
+ }
80
+
81
+ /**
82
+ * Write <packsDir>/<pack.name>.json. Existing name: unchanged canonical hash →
83
+ * {noop:true}; changed with the same version string → auto-bump patch (spec
84
+ * carried decision 6). Caller validates the pack first (pack-validate).
85
+ * @returns {{path, hash, overwritten, bumped}|{noop:true, path}}
86
+ */
87
+ function writePack(pack) {
88
+ fs.mkdirSync(packsDir(), { recursive: true, mode: 0o700 });
89
+ const file = path.join(packsDir(), `${pack.name}.json`);
90
+ let existing = null;
91
+ try { existing = JSON.parse(stripBom(fs.readFileSync(file, 'utf-8'))); } catch { /* new pack */ }
92
+ const toWrite = { ...pack };
93
+ let bumped = false;
94
+ if (existing) {
95
+ if (canonicalHash(existing) === canonicalHash(toWrite)) { return { noop: true, path: file }; }
96
+ if (existing.version === toWrite.version) {
97
+ toWrite.version = bumpPatch(toWrite.version);
98
+ bumped = true;
99
+ }
100
+ }
101
+ writeFileAtomic(file, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
102
+ return { path: file, hash: canonicalHash(toWrite), overwritten: !!existing, bumped, version: toWrite.version };
103
+ }
104
+
105
+ /** @returns {{packs: Array<{name,kind,version,description}>, warnings: string[]}} name-sorted */
106
+ function listPacks() {
107
+ let entries = [];
108
+ try { entries = fs.readdirSync(packsDir()); } catch { /* no dir yet */ }
109
+ const packs = [];
110
+ const warnings = [];
111
+ for (const f of entries) {
112
+ if (!f.endsWith('.json')) { continue; }
113
+ try {
114
+ const p = JSON.parse(stripBom(fs.readFileSync(path.join(packsDir(), f), 'utf-8')));
115
+ packs.push({ name: p.name, kind: p.kind, version: p.version, description: p.description || '' });
116
+ } catch (err) { warnings.push(`${f}: ${err.message}`); }
117
+ }
118
+ packs.sort((a, b) => String(a.name).localeCompare(String(b.name)));
119
+ return { packs, warnings };
120
+ }
121
+
122
+ /** @returns {{removed: boolean}} */
123
+ function rmPack(name) {
124
+ const r = resolvePackRef(name);
125
+ if (r.error || r.kind !== 'name') { return { removed: false }; }
126
+ try { fs.unlinkSync(path.join(packsDir(), `${r.name}.json`)); return { removed: true }; }
127
+ catch { return { removed: false }; }
128
+ }
129
+
130
+ module.exports = { packsDir, canonicalHash, resolvePackRef, readPack, writePack, listPacks, rmPack };
@@ -0,0 +1,113 @@
1
+ // src/pack/pack-validate.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module pack/pack-validate
6
+ * Spec §5.6. Runs at `pack save` (hard-fail), `pack show` (reports, never
7
+ * fails), and run-time resolve (hard-fail through the envelope, pre-spend).
8
+ * Kind matching itself (PACK_KIND_MISMATCH) is the caller's check — this
9
+ * module validates a pack's internal consistency.
10
+ */
11
+
12
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
13
+ const SEMVER_RE = /^\d+\.\d+\.\d+/;
14
+ const KINDS = ['council', 'fanout', 'solo'];
15
+
16
+ /** Per-kind allowed `options` keys (spec §5.1; solo UI-suppression key per Task 0's verified flag set).
17
+ * v4.5 HOLD-gate decision 2 (final-review F1): `agent`/`thinking`/`summaryLength`
18
+ * are inert on EVERY council surface — handleCouncilRun never reads a pack-filled
19
+ * one, and the engine hardcodes agent 'Plan'/summaryLength 'verbose' regardless.
20
+ * Dropped from `council` pre-release rather than shipped as dead weight a pack
21
+ * author would reasonably expect to work; a council pack that still sets one now
22
+ * fails save/run validation (PACK_INVALID) like any other unknown option for the
23
+ * kind. They remain valid (and functional) on `fanout`/`solo`. */
24
+ const KIND_OPTIONS = Object.freeze({
25
+ council: ['timeout', 'maxCost', 'gateway', 'debate'],
26
+ fanout: ['timeout', 'maxCost', 'gateway', 'agent', 'thinking', 'summaryLength',
27
+ 'noContext', 'contextTurns', 'contextMaxTokens'],
28
+ solo: ['timeout', 'maxCost', 'gateway', 'agent', 'thinking', 'summaryLength',
29
+ 'noUi', 'noContext', 'contextTurns', 'contextMaxTokens'],
30
+ });
31
+
32
+ /** Fields allowed per kind beyond the common set. */
33
+ const COMMON_FIELDS = ['schemaVersion', 'type', 'name', 'version', 'kind', 'description', 'options', 'briefing'];
34
+ const KIND_FIELDS = Object.freeze({
35
+ council: [...COMMON_FIELDS, 'bench', 'chair', 'critic', 'lenses'],
36
+ fanout: [...COMMON_FIELDS, 'bench'],
37
+ solo: [...COMMON_FIELDS, 'model'],
38
+ });
39
+
40
+ /**
41
+ * @param {object} pack
42
+ * @param {{mode: 'save'|'run'}} opts
43
+ * @returns {{ok:true, warnings:string[]} | {ok:false, errors:string[]}}
44
+ */
45
+ function validatePack(pack, { mode } = { mode: 'run' }) {
46
+ const errors = [];
47
+ const warnings = [];
48
+ if (!pack || typeof pack !== 'object') { return { ok: false, errors: ['pack is not an object'] }; }
49
+ if (pack.schemaVersion !== 1) { errors.push(`schemaVersion must be 1 (got ${pack.schemaVersion})`); }
50
+ if (pack.type !== 'pack') { errors.push(`type must be 'pack' (got '${pack.type}')`); }
51
+ if (typeof pack.name !== 'string' || !NAME_RE.test(pack.name)) { errors.push(`invalid pack name '${pack.name}'`); }
52
+ if (typeof pack.version !== 'string' || !SEMVER_RE.test(pack.version)) { errors.push(`version must be semver-shaped (got '${pack.version}')`); }
53
+ if (!KINDS.includes(pack.kind)) {
54
+ errors.push(`kind must be one of ${KINDS.join('|')} (got '${pack.kind}')`);
55
+ return { ok: false, errors };
56
+ }
57
+
58
+ for (const key of Object.keys(pack)) {
59
+ if (!KIND_FIELDS[pack.kind].includes(key)) { errors.push(`field '${key}' is not valid for kind '${pack.kind}'`); }
60
+ }
61
+ const opts = pack.options || {};
62
+ if (typeof opts !== 'object' || Array.isArray(opts)) { errors.push('options must be an object'); }
63
+ else {
64
+ for (const key of Object.keys(opts)) {
65
+ if (!KIND_OPTIONS[pack.kind].includes(key)) { errors.push(`unknown option '${key}' for kind '${pack.kind}'`); }
66
+ }
67
+ }
68
+
69
+ const { getEffectiveAliases, getCouncilWithSource } = require('../utils/config');
70
+ const aliases = getEffectiveAliases();
71
+ const seatOk = (m) => typeof m === 'string' && (m.includes('/') || !!aliases[m]);
72
+
73
+ if (pack.kind === 'solo') {
74
+ if (typeof pack.model !== 'string' || !pack.model.trim()) { errors.push('solo pack requires model'); }
75
+ else if (!seatOk(pack.model)) { errors.push(`unresolvable model '${pack.model}'`); }
76
+ } else {
77
+ const bench = pack.bench;
78
+ if (typeof bench === 'string') {
79
+ const { members } = getCouncilWithSource(bench, []);
80
+ if (!members) { errors.push(`bench names unknown council '${bench}'`); }
81
+ else if (pack.kind === 'council' && (pack.lenses || pack.chair || pack.critic)) {
82
+ warnings.push('bench is by-name: member-level checks (chair/critic/lenses vs seats) deferred to run time');
83
+ }
84
+ } else if (Array.isArray(bench) && bench.length >= 2) {
85
+ const bad = bench.filter((m) => !seatOk(m));
86
+ if (bad.length) { errors.push(`unresolvable bench member(s): ${bad.join(', ')}`); }
87
+ if (pack.kind === 'council') {
88
+ if (pack.chair && bench.includes(pack.chair)) { errors.push(`chair '${pack.chair}' is a bench seat — the chair must not review`); }
89
+ if (pack.critic && !bench.includes(pack.critic)) { errors.push(`critic '${pack.critic}' must be one of the bench seats`); }
90
+ if (pack.critic && pack.lenses) { errors.push('critic and lenses are mutually exclusive'); }
91
+ if (Array.isArray(pack.lenses) && pack.lenses.length !== bench.length) {
92
+ errors.push(`lenses needs exactly one lens per seat (${bench.length} seats, got ${pack.lenses.length})`);
93
+ }
94
+ }
95
+ } else {
96
+ errors.push('bench must be a council name or an array of 2+ members');
97
+ }
98
+ }
99
+
100
+ const tplRef = pack.briefing && pack.briefing.template;
101
+ if (tplRef !== undefined && tplRef !== null) {
102
+ const { resolveTemplate } = require('../template/store');
103
+ const t = resolveTemplate(tplRef);
104
+ if (t.error) {
105
+ if (mode === 'save') { warnings.push(`briefing.template '${tplRef}' does not resolve on this machine (packs travel; it may exist where the pack is used)`); }
106
+ else { errors.push(`briefing.template '${tplRef}' does not resolve`); }
107
+ }
108
+ }
109
+
110
+ return errors.length ? { ok: false, errors } : { ok: true, warnings };
111
+ }
112
+
113
+ module.exports = { validatePack, KIND_OPTIONS, KINDS };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Electron install-state probes (#76).
3
+ *
4
+ * Distinguishes the two states isElectronUsable() collapses into one boolean:
5
+ * 'package-missing' — the electron optionalDependency was never installed
6
+ * (possibly deliberate: headless-only install)
7
+ * 'binary-missing' — package present but dist/<exe> absent (interrupted
8
+ * postinstall, AV quarantine) — repairElectron territory
9
+ * 'ok' — the resolved exe exists on disk
10
+ *
11
+ * Also owns electronDirFor(): the per-install dual-root layout probe. npm
12
+ * NESTS electron under amicus/node_modules in a global install but HOISTS it
13
+ * to a sibling in the npx cache — the same layout asymmetry that broke the
14
+ * engine probe (#69). In-process require.resolve handles hoisting via walk-up,
15
+ * which is why the running copy probes itself fine; only CROSS-install probing
16
+ * (doctor) needs this explicit dual-root check.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const path = require('path');
22
+ const fsDefault = require('fs');
23
+ const { isElectronUsable, defaultElectronDir } = require('./electron-install');
24
+
25
+ /**
26
+ * Locate the electron package dir serving a given amicus install. Checks the
27
+ * nested root first (require.resolve walk-up order), then the hoisted sibling.
28
+ * @param {string} pkgDir amicus package root
29
+ * @param {{fs?:object}} [deps]
30
+ * @returns {string|null} electron package dir, or null when not installed
31
+ */
32
+ function electronDirFor(pkgDir, { fs = fsDefault } = {}) {
33
+ const candidates = [
34
+ path.join(pkgDir, 'node_modules', 'electron'),
35
+ path.join(path.dirname(pkgDir), 'electron'),
36
+ ];
37
+ for (const dir of candidates) {
38
+ try { if (fs.existsSync(dir)) { return dir; } } catch { /* unreadable root */ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Probe one electron package dir into the 3-state verdict.
45
+ * `electronDir: null` (electronDirFor found nothing) short-circuits to
46
+ * package-missing; an explicit dir without package.json reports the same.
47
+ * @param {{electronDir?:string|null, fs?:object, platform?:string, env?:object}} [opts]
48
+ * @returns {{state:'ok'|'package-missing'|'binary-missing', electronDir:string|null}}
49
+ */
50
+ function probeElectronState({
51
+ electronDir = defaultElectronDir(), fs = fsDefault, platform = process.platform, env = process.env,
52
+ } = {}) {
53
+ if (!electronDir) { return { state: 'package-missing', electronDir: null }; }
54
+ let hasPkg = false;
55
+ try { hasPkg = fs.existsSync(path.join(electronDir, 'package.json')); } catch { /* unreadable */ }
56
+ if (!hasPkg) { return { state: 'package-missing', electronDir }; }
57
+ const usable = isElectronUsable({ electronDir, fs, platform, env });
58
+ return { state: usable ? 'ok' : 'binary-missing', electronDir };
59
+ }
60
+
61
+ module.exports = { electronDirFor, probeElectronState };
@@ -42,6 +42,12 @@ function deriveLegIds(waveId, count) {
42
42
  * stdout — tests), councilRunId? / councilName? (v4.3 §7.2: stamped onto legs),
43
43
  * fallback? / catalog? (v4.3 Task 18 §6.2: opt-in substitution; off/absent unchanged),
44
44
  * retryContexts? / retryOfWaveId? (v4.3 Task 19: --retry-failed relaunch seam; absent -> byte-identical),
45
+ * pack? (v4.5 Task 13: {name,version,hash,source} record when launched via
46
+ * --pack; absent/null -> omitted from wave metadata.json/wave.json, not stored as null.
47
+ * v4.5 final-review F2: when absent, wave.json still inherits a pack the caller
48
+ * pre-seeded onto this wave dir's metadata.json before calling runFanout — see
49
+ * `metaPack` below. That is how an MCP-spawned child, which never receives
50
+ * --pack itself, still ends up with the pack on its wave.json),
45
51
  * server? + serverClient? (v4.4.1 Task 0.5: an ALREADY-STARTED OpenCode server
46
52
  * to run this wave's legs on. Both or neither. When supplied this wave never
47
53
  * starts a server and never closes one — see the seam comment in step 4.
@@ -80,7 +86,7 @@ async function runFanout(options) {
80
86
  // `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
81
87
  // waveDir is optional (only the post-creation caller has one).
82
88
  const errorWave = (waveId, message, waveDir) => {
83
- const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, createdAt, completedAt: new Date().toISOString(), status: 'error' });
89
+ const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack, createdAt, completedAt: new Date().toISOString(), status: 'error' });
84
90
  doc.error = message;
85
91
  doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
86
92
  // best-effort: an unwritable wave dir must not mask the real error
@@ -137,13 +143,24 @@ async function runFanout(options) {
137
143
  const waveDir = getSessionDir(project, waveId);
138
144
  fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 });
139
145
  fs.writeFileSync(path.join(waveDir, 'briefing.md'), options.prompt, { mode: 0o600 });
140
- writeWaveMetadata(waveDir, {
146
+ const waveMeta = writeWaveMetadata(waveDir, {
141
147
  taskId: waveId, type: 'wave', status: 'running', mode: 'headless',
142
148
  models: legs.map(l => (l.ok ? l.model : l.modelInput)), legs: legIds,
143
149
  briefing: String(options.prompt).slice(0, 200),
144
150
  promptMeta: options.promptMeta || null,
151
+ ...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
145
152
  pid: process.pid, project, createdAt,
146
153
  });
154
+ // v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
155
+ // resolution rule), but mcp-server.js pre-seeds THIS wave dir's
156
+ // metadata.json with the pack it already resolved in-process before
157
+ // spawning the child. writeWaveMetadata read-merges (fanout-wave-io.js),
158
+ // so its RETURN VALUE already carries that pre-seeded pack when
159
+ // options.pack is absent here — inherit from it below rather than
160
+ // re-reading the file (mirrors the inherit idiom in
161
+ // result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
162
+ // it loaded for an unrelated reason).
163
+ const metaPack = waveMeta.pack;
147
164
  emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
148
165
 
149
166
  // 2b. All legs failed to route (#61 perf): no leg will ever touch the
@@ -154,7 +171,7 @@ async function runFanout(options) {
154
171
  const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
155
172
  const completedAt = new Date().toISOString();
156
173
  const wave = buildWaveResult({
157
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt, notices,
174
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
158
175
  });
159
176
  return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
160
177
  exitCode: waveExitCode(wave.status),
@@ -265,7 +282,7 @@ async function runFanout(options) {
265
282
  const completedAt = new Date().toISOString();
266
283
  const signalled = waveAbort.signal();
267
284
  const wave = buildWaveResult({
268
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
285
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
269
286
  status: signalled ? 'aborted' : null, notices,
270
287
  });
271
288
  const exitCode = signalled