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,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 };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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
|