amicus 4.6.3 → 4.7.0
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 +283 -0
- package/README.md +19 -7
- package/bin/amicus.js +31 -0
- package/docs/ROADMAP.md +143 -36
- package/docs/configuration.md +51 -3
- package/docs/council.md +63 -10
- package/docs/doc-system.md +8 -7
- package/docs/schemas.md +10 -1
- package/docs/troubleshooting.md +27 -1
- package/docs/usage.md +59 -13
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +52 -14
- package/electron/workspace-ui/workspace-app.js +14 -3
- package/electron/workspace-ui/workspace-lazy.js +233 -0
- package/electron/workspace-ui/workspace-matrix.js +12 -1
- package/electron/workspace-ui/workspace-panels.js +24 -171
- package/electron/workspace-ui/workspace-render.js +6 -2
- package/electron/workspace-ui/workspace-seats.js +68 -0
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +5 -2
- package/schemas/council-run.schema.json +1 -0
- package/schemas/council-stats.schema.json +9 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
- package/skills/second-opinion/MODEL-NOTES.md +5 -4
- package/skills/sidecar/SKILL.md +7 -2
- package/src/cli-council-run-bench.js +86 -0
- package/src/cli-handlers-council-run.js +65 -81
- package/src/cli-handlers-council.js +17 -5
- package/src/cli-handlers-fanout.js +179 -0
- package/src/cli-handlers-pack.js +24 -10
- package/src/cli-handlers-run.js +19 -161
- package/src/cli-template-args.js +48 -0
- package/src/cli.js +39 -46
- package/src/council/debate.js +89 -10
- package/src/council/ledger.js +72 -11
- package/src/council/report.js +17 -6
- package/src/council/run-assemble.js +15 -3
- package/src/council/run-budget.js +2 -2
- package/src/council/run-chair.js +61 -5
- package/src/council/run-debate.js +51 -67
- package/src/council/run-launch.js +9 -2
- package/src/council/run-retry.js +4 -1
- package/src/council/run-stage1-launch.js +94 -0
- package/src/council/run-stage2.js +25 -4
- package/src/council/run-stages.js +79 -86
- package/src/council/run-state.js +10 -2
- package/src/council/run.js +26 -2
- package/src/council/tally.js +6 -2
- package/src/mcp-council-awareness.js +1 -0
- package/src/mcp-council-bench.js +4 -0
- package/src/mcp-council-run.js +10 -0
- package/src/mcp-server.js +114 -54
- package/src/mcp-tools.js +12 -5
- package/src/pack/pack-cli.js +1 -1
- package/src/pack/pack-forward.js +12 -4
- package/src/pack/pack-resolve.js +3 -0
- package/src/pack/pack-store.js +20 -3
- package/src/pack/pack-validate.js +5 -1
- package/src/sidecar/budget.js +38 -4
- package/src/sidecar/fanout-budget.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +7 -3
- package/src/sidecar/fanout-wave-io.js +13 -1
- package/src/sidecar/fanout.js +11 -9
- package/src/sidecar/list-limit.js +50 -0
- package/src/sidecar/list-search.js +69 -0
- package/src/sidecar/read.js +90 -5
- package/src/sidecar/start-metadata.js +58 -0
- package/src/sidecar/start.js +8 -43
- package/src/sidecar/workspace-auto-open.js +2 -2
- package/src/spend-query.js +2 -1
- package/src/template/apply.js +7 -4
- package/src/template/render.js +6 -2
- package/src/template/store.js +1 -1
- package/src/utils/cli-preflight.js +27 -1
- package/src/utils/config.js +15 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +6 -1
- package/src/utils/session-index-tmp-sweep.js +18 -3
- package/src/utils/session-index.js +1 -0
- package/src/utils/session-metadata-tmp-sweep.js +24 -4
- package/src/utils/spend-ledger.js +11 -4
- package/src/utils/validators.js +16 -0
|
@@ -11,82 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { failJson, buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
|
|
14
|
-
const { validateTaskId } = require('./utils/validators');
|
|
14
|
+
const { validateTaskId, validateTag } = require('./utils/validators');
|
|
15
15
|
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
16
16
|
// v4.6 Plan 4 Task 2: renderRunHuman moved to its own leaf (size gate); this
|
|
17
17
|
// file re-exports it below so every existing require() of this path still
|
|
18
18
|
// resolves it unchanged.
|
|
19
19
|
const { renderRunHuman } = require('./cli-council-run-render');
|
|
20
|
+
const { parseList, sanitizeCouncilName, resolveBench } = require('./cli-council-run-bench');
|
|
21
|
+
const { applyTemplateForArgs } = require('./cli-template-args');
|
|
20
22
|
|
|
21
23
|
const CHAIR_DEFAULT = 'deepseek';
|
|
22
24
|
|
|
23
|
-
function parseList(value) {
|
|
24
|
-
return String(value).split(',').map(s => s.trim()).filter(Boolean);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Sanitize the internal `--council-name` passthrough before it can reach the
|
|
29
|
-
* spend ledger's `councilName` column (v4.3 Task 4 review fix, spec §7.3:
|
|
30
|
-
* spend docs hold only ids/numbers/paths "by construction"). That value is
|
|
31
|
-
* user-supplied (via mcp-council-run.js, ultimately an MCP caller's `input`),
|
|
32
|
-
* unbounded, and unvalidated — unlike a real `--council <preset>`, which is
|
|
33
|
-
* catalog-validated upstream. Strips control/non-printable characters, trims,
|
|
34
|
-
* and caps length so a hostile/malformed passthrough can't land raw in a
|
|
35
|
-
* `--group-by council` rollup. Precedence is untouched by this: it's applied
|
|
36
|
-
* only to the passthrough branch, never to the catalog-validated preset name.
|
|
37
|
-
* @param {string} name @returns {string|null} sanitized name, or null if empty after cleanup
|
|
38
|
-
*/
|
|
39
|
-
function sanitizeCouncilName(name) {
|
|
40
|
-
// eslint-disable-next-line no-control-regex -- deliberately stripping C0/DEL control chars
|
|
41
|
-
const cleaned = String(name).replace(/[\x00-\x1F\x7F]/g, '').trim().slice(0, 64);
|
|
42
|
-
return cleaned || null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Resolve bench models from --models XOR --council (mirrors handleFanout).
|
|
47
|
-
* Also returns `presetName` (v4.3 Task 3, spec §7.1: trimmed --council name,
|
|
48
|
-
* else null) and `droppedMembers`: a preset's own drops, or — bare --models —
|
|
49
|
-
* the parsed `--dropped-members` MCP→child passthrough (v4.6 Plan 4 Task 4b).
|
|
50
|
-
*/
|
|
51
|
-
function resolveBench(args, useJson) {
|
|
52
|
-
const hasModels = typeof args.models === 'string' && args.models.trim();
|
|
53
|
-
const hasCouncil = args.council !== undefined && args.council !== false;
|
|
54
|
-
if (hasModels && hasCouncil) {
|
|
55
|
-
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
56
|
-
message: 'Error: pass exactly one of --models / --council, not both' }) };
|
|
57
|
-
}
|
|
58
|
-
if (!hasModels && !hasCouncil) {
|
|
59
|
-
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
60
|
-
message: 'Error: council run needs --models a,b,c or --council <preset> (at least 2 seats)' }) };
|
|
61
|
-
}
|
|
62
|
-
if (hasCouncil) {
|
|
63
|
-
if (typeof args.council !== 'string' || !args.council.trim()) {
|
|
64
|
-
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
65
|
-
message: 'Error: --council requires a council name (e.g. --council budget)' }) };
|
|
66
|
-
}
|
|
67
|
-
const { resolveCouncilMembers } = require('./utils/config');
|
|
68
|
-
const { readCache } = require('./utils/model-catalog');
|
|
69
|
-
const catalog = (readCache() || {}).models || [];
|
|
70
|
-
const presetName = args.council.trim();
|
|
71
|
-
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
72
|
-
if (expanded.error) {
|
|
73
|
-
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }) };
|
|
74
|
-
}
|
|
75
|
-
// v4.5 Wave 2 → Plan 4 Task 4: threaded into runCouncil's options — the
|
|
76
|
-
// sink now announces each dropped member, with reason, on every transport and surface.
|
|
77
|
-
return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
|
|
78
|
-
}
|
|
79
|
-
if (args['dropped-members'] === undefined) {
|
|
80
|
-
return { bench: parseList(args.models), presetName: null, droppedMembers: [] };
|
|
81
|
-
}
|
|
82
|
-
let dm; try { dm = JSON.parse(args['dropped-members']); } catch { dm = null; }
|
|
83
|
-
if (!Array.isArray(dm) || !dm.every(d => d && typeof d.member === 'string' && typeof d.reason === 'string')) {
|
|
84
|
-
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
85
|
-
message: 'Error: --dropped-members must be a JSON array of {member, reason} entries' }) };
|
|
86
|
-
}
|
|
87
|
-
return { bench: parseList(args.models), presetName: null, droppedMembers: dm };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
25
|
/**
|
|
91
26
|
* Default real helpers; tests override via depsOverride (mirrors
|
|
92
27
|
* cli-handlers-spend.js's realDeps()/depsOverride convention).
|
|
@@ -112,6 +47,27 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
112
47
|
// existing application point exactly like a typed --template.
|
|
113
48
|
let packRecord = null;
|
|
114
49
|
const explicitKeys = args.__explicit || new Set();
|
|
50
|
+
// v4.7 PR6: these all parse as boolean `true` when typed without a value
|
|
51
|
+
// (src/cli.js:101) and reached runCouncil as `true`, a NaN, or a bogus path.
|
|
52
|
+
// Voice matches the R5 -o/--out precedent (cli-handlers-council.js:183).
|
|
53
|
+
for (const flag of ['out-dir', 'claude-review', 'run-id']) {
|
|
54
|
+
if (!explicitKeys.has(flag)) { continue; }
|
|
55
|
+
const v = args[flag];
|
|
56
|
+
if (typeof v !== 'string' || v === '') {
|
|
57
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --${flag} requires a value` });
|
|
58
|
+
}
|
|
59
|
+
if (v.startsWith('-')) {
|
|
60
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --${flag} cannot start with '-': got '${v}'` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// --timeout is DEFAULTS-seeded to 15 (src/cli.js:31), so `!== undefined` proves
|
|
64
|
+
// nothing; NaN is the real hole — it passes the `<= 0` guard below.
|
|
65
|
+
if (explicitKeys.has('timeout') && (typeof args.timeout !== 'number' || !Number.isFinite(args.timeout))) {
|
|
66
|
+
// Do NOT echo args.timeout: parseArgs already ran parseInt, so a typed
|
|
67
|
+
// `--timeout abc` reads back as NaN and quoting it shows the user a value
|
|
68
|
+
// they never typed. Boolean `true` (bare flag) has the same problem.
|
|
69
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout requires a number' });
|
|
70
|
+
}
|
|
115
71
|
if (args.pack !== undefined) {
|
|
116
72
|
const { applyPackToArgs } = require('./pack/pack-resolve');
|
|
117
73
|
const pr = applyPackToArgs({
|
|
@@ -146,17 +102,14 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
146
102
|
promptRes = { prompt: undefined, promptMeta: null };
|
|
147
103
|
}
|
|
148
104
|
let templateMeta = null;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
} else if (args.artifact !== undefined || args.var !== undefined) {
|
|
158
|
-
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --artifact/--var require --template (expansion happens only in template files)' });
|
|
159
|
-
}
|
|
105
|
+
const tpl = applyTemplateForArgs(args, promptRes.prompt, useJson);
|
|
106
|
+
if (tpl.fail !== undefined) { return tpl.fail; }
|
|
107
|
+
// The trailing `templateMeta =` is NOT copy-paste drift against handleFanout's
|
|
108
|
+
// otherwise-identical call: it feeds `template: templateMeta` on the run.json
|
|
109
|
+
// seed below (the `template:` field of the runCouncil options object). Drop it
|
|
110
|
+
// and every --template council run silently records
|
|
111
|
+
// `template: null`. handleFanout has no such field, which is why its call is shorter.
|
|
112
|
+
if (tpl.applied) { promptRes = { prompt: tpl.prompt, promptMeta: tpl.promptMeta }; templateMeta = tpl.templateMeta; }
|
|
160
113
|
|
|
161
114
|
const benchRes = resolveBench(args, useJson);
|
|
162
115
|
if (benchRes.fail !== undefined) { return benchRes.fail; }
|
|
@@ -194,14 +147,29 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
194
147
|
}
|
|
195
148
|
const lenses = (typeof args.lenses === 'string' && args.lenses.trim()) ? parseList(args.lenses) : null;
|
|
196
149
|
if (critic && lenses) {
|
|
150
|
+
// T11-d: no packSuffix() here (unlike the chair/critic-in-bench checks
|
|
151
|
+
// above) — it would only ever contribute ''. pack-validate.js now rejects
|
|
152
|
+
// a pack supplying both critic and lenses before this handler ever runs
|
|
153
|
+
// (PACK_INVALID, pre-spend, via pack-resolve.js's validatePack call), and
|
|
154
|
+
// pack-resolve.js:140/143 already suppress the mixed pack-field x
|
|
155
|
+
// explicit-flag crossings (a pack-filled critic is skipped when --lenses
|
|
156
|
+
// is explicit, and vice versa). So whenever this branch fires, both
|
|
157
|
+
// critic and lenses are always explicit flags, never pack-attributed.
|
|
197
158
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
198
|
-
message:
|
|
159
|
+
message: 'Error: --critic and --lenses are mutually exclusive in v4.0' });
|
|
199
160
|
}
|
|
200
161
|
if (lenses && lenses.length !== bench.length) {
|
|
201
162
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
202
163
|
message: `Error: --lenses needs exactly one lens per seat (${bench.length} seats, got ${lenses.length})` });
|
|
203
164
|
}
|
|
204
|
-
|
|
165
|
+
// v4.7 PR6: this check is POST-pack-merge and ungated, so it is the only one a
|
|
166
|
+
// pack-filled value passes through — `timeout` is a legal council pack option
|
|
167
|
+
// (pack-validate.js KIND_OPTIONS) and validatePack checks the key name, never
|
|
168
|
+
// the value type. The old `<= 0` test alone let `{timeout: true}` past (true
|
|
169
|
+
// coerces to 1) and `{timeout: "abc"}` past as NaN, reproducing the very bug
|
|
170
|
+
// the typed-flag guard above closes. Same shape as --max-cost's check below.
|
|
171
|
+
if (args.timeout !== undefined
|
|
172
|
+
&& (typeof args.timeout !== 'number' || !Number.isFinite(args.timeout) || args.timeout <= 0)) {
|
|
205
173
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' });
|
|
206
174
|
}
|
|
207
175
|
const mc = args['max-cost'];
|
|
@@ -212,6 +180,15 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
212
180
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
213
181
|
message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` });
|
|
214
182
|
}
|
|
183
|
+
// v4.7 F8 (D13): reject-style (unlike sanitizeCouncilName, which cleans) —
|
|
184
|
+
// a stored tag is a user-chosen search key, so silent truncation/stripping
|
|
185
|
+
// would make --search/--group-by tag miss it.
|
|
186
|
+
if (args.tag !== undefined) {
|
|
187
|
+
const tagCheck = validateTag(args.tag);
|
|
188
|
+
if (!tagCheck.ok) {
|
|
189
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: tagCheck.error });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
215
192
|
let runId;
|
|
216
193
|
if (args['run-id']) {
|
|
217
194
|
const check = validateTaskId(String(args['run-id']));
|
|
@@ -227,6 +204,12 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
227
204
|
const runDir = args['out-dir']
|
|
228
205
|
? path.resolve(project, String(args['out-dir']))
|
|
229
206
|
: path.resolve(project, `council-${runId}`);
|
|
207
|
+
// v4.7 PR6: MCP has fenced this since v4.5 (mcp-council-run.js:137-141); the CLI
|
|
208
|
+
// never did, so `--out-dir ../../x` wrote outside the project and exited 0.
|
|
209
|
+
const { isPathInside } = require('./project-root-allowlist');
|
|
210
|
+
if (!isPathInside(runDir, project)) {
|
|
211
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --out-dir must stay inside the project: '${args['out-dir']}' resolves outside ${project}` });
|
|
212
|
+
}
|
|
230
213
|
|
|
231
214
|
const { resolveGatewayMode, loadConfig } = require('./utils/config');
|
|
232
215
|
const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
|
|
@@ -254,6 +237,7 @@ async function handleCouncilRun(args, depsOverride = {}) {
|
|
|
254
237
|
councilName,
|
|
255
238
|
template: templateMeta, // F9 (v4.5): null when no --template; additive on the run.json seed (run-state.js).
|
|
256
239
|
pack: packRecord, // v4.5 Task 12 (B7/F5): null when no --pack; additive on the run.json seed (run-state.js).
|
|
240
|
+
tag: args.tag, // v4.7 F8: undefined when no --tag; Task 3 stores it on the run.json seed.
|
|
257
241
|
droppedMembers: benchRes.droppedMembers, // v4.5 Wave 2: [] when nothing dropped; additive on the run.json seed (run-state.js).
|
|
258
242
|
// v4.1 §4.5b/§4.5d. `--claude-review` is resolved here but VALIDATED by the
|
|
259
243
|
// engine's preflightClaudeReview (run-assemble.js): the reserved-seat and
|
|
@@ -60,10 +60,14 @@ function renderRecord(r) {
|
|
|
60
60
|
}
|
|
61
61
|
function renderStats(agg) {
|
|
62
62
|
if (!agg.length) { return 'No council runs recorded yet.\n'; }
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// v4.7 GOA-7 D10: group keys may be executable ids (>16 chars) — size the
|
|
64
|
+
// model column to the longest key; legacy (alias-keyed) groups get a notes
|
|
65
|
+
// marker beside low-N.
|
|
66
|
+
const w = Math.max(16, ...agg.map(a => String(a.model).length));
|
|
67
|
+
return 'model'.padEnd(w) + ' runs avg-cred confirm fact-err notes\n' +
|
|
68
|
+
agg.map(a => `${String(a.model).padEnd(w)} ${String(a.runs).padStart(4)} ` +
|
|
65
69
|
`${fmt(a.avgStreetCredPeersOnly)} ${fmt(a.lifetimeConfirmRate)} ${fmt(a.lifetimeFactErrorRate)}` +
|
|
66
|
-
`${a.lowN ? ' low-N' : ''}`).join('\n') + '\n';
|
|
70
|
+
`${a.lowN ? ' low-N' : ''}${a.legacy ? ' legacy' : ''}`).join('\n') + '\n';
|
|
67
71
|
}
|
|
68
72
|
function fmt(v) { return (v === null || v === undefined) ? ' — ' : v.toFixed(2); }
|
|
69
73
|
|
|
@@ -167,8 +171,16 @@ function runVerdict(args, useJson) {
|
|
|
167
171
|
// (renameSync TypeError on a non-string path) leaving an orphaned
|
|
168
172
|
// true.tmp-<pid>; the empty string silently falls through to the default
|
|
169
173
|
// path. Name the flag and refuse both — the unknown-flag precedent.
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
// R5 (v4.7): a dash-leading value ('-x') is a well-formed string as far as
|
|
175
|
+
// parseArgs is concerned (it normalizes, it does not validate) — refuse it
|
|
176
|
+
// here too, or it resolves straight through to writeVerdictAtomic('-x', ...)
|
|
177
|
+
// and writes a file literally named '-x' in cwd. Same failure class as R1,
|
|
178
|
+
// one form short.
|
|
179
|
+
if (args.out !== undefined && (typeof args.out !== 'string' || args.out === '' || args.out.startsWith('-'))) {
|
|
180
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
181
|
+
message: (typeof args.out !== 'string' || args.out === '')
|
|
182
|
+
? '-o/--out requires a value'
|
|
183
|
+
: `-o/--out cannot start with '-': got '${args.out}'`,
|
|
172
184
|
hint: 'amicus council verdict <tally.json> [--decisions <decisions.json>] [-o|--out <out.json>]' });
|
|
173
185
|
}
|
|
174
186
|
const outPath = args.out || './verdict.json';
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI handler for the fanout command (multi-model parallel runs).
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from cli-handlers-run.js (v4.7 PR0) to keep that file
|
|
5
|
+
* under the 300-line gate before F8 adds --tag forwarding. Whole-handler
|
|
6
|
+
* split precedent: cli-handlers-resume-continue.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { validateTaskId, validateTag } = require('./utils/validators');
|
|
12
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
13
|
+
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
14
|
+
const { applyTemplateForArgs } = require('./cli-template-args');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handle 'amicus fanout' command (F4).
|
|
18
|
+
* Returns the wave exit code: 0 all complete, 2 partial, 1 none/hard failure,
|
|
19
|
+
* 130/143 when the wave was signal-aborted.
|
|
20
|
+
*/
|
|
21
|
+
async function handleFanout(args) {
|
|
22
|
+
const useJson = !!args.json;
|
|
23
|
+
|
|
24
|
+
// v4.7 F8 (D13): a retried wave replays each leg's own saved context
|
|
25
|
+
// byte-identical — there is no fresh session to attach a new --tag to, so
|
|
26
|
+
// reject the combination before the retry-failed dispatch below.
|
|
27
|
+
if (args.tag !== undefined && args['retry-failed']) {
|
|
28
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --tag cannot be combined with --retry-failed' }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --retry-failed <waveId> (v4.3 Task 19, spec 6.1): a completely different
|
|
32
|
+
// path from the --prompt/--models launch below (no briefing, no required
|
|
33
|
+
// --models — the original wave's failed legs supply their own saved
|
|
34
|
+
// context) — dispatch BEFORE any of that validation runs. --models here is
|
|
35
|
+
// optional and, when present, filters which failed legs get retried.
|
|
36
|
+
// --pack is likewise ignored on this path (deliberate, same precedent: retry
|
|
37
|
+
// replays the wave's recorded per-leg config; flags that reshape a wave don't apply).
|
|
38
|
+
if (args['retry-failed']) {
|
|
39
|
+
const { retryFailedWave } = require('./sidecar/fanout-retry');
|
|
40
|
+
const { parseModelsList } = require('./sidecar/fanout-validate');
|
|
41
|
+
const { exitCode, errorDoc } = await retryFailedWave(String(args['retry-failed']), args.cwd || process.cwd(), {
|
|
42
|
+
models: parseModelsList(args.models), json: useJson,
|
|
43
|
+
});
|
|
44
|
+
if (errorDoc && useJson) { process.stdout.write(JSON.stringify(errorDoc) + '\n'); }
|
|
45
|
+
return exitCode;
|
|
46
|
+
}
|
|
47
|
+
const packRecord = require('./pack/pack-cli').applyPackOrExit(args, 'fanout', useJson);
|
|
48
|
+
|
|
49
|
+
// FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
|
|
50
|
+
// --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
|
|
51
|
+
// value silently fell through to resolveGatewayMode's pass-through instead
|
|
52
|
+
// of failing fast with a clear error.
|
|
53
|
+
if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
|
|
54
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
58
|
+
let promptRes;
|
|
59
|
+
if (args.prompt !== undefined || args['prompt-file'] !== undefined || args.template === undefined) {
|
|
60
|
+
promptRes = resolvePromptSource(args);
|
|
61
|
+
if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
|
|
62
|
+
} else {
|
|
63
|
+
promptRes = { prompt: undefined, promptMeta: null };
|
|
64
|
+
}
|
|
65
|
+
const tpl = applyTemplateForArgs(args, promptRes.prompt, useJson);
|
|
66
|
+
if (tpl.fail !== undefined) { process.exit(tpl.fail); }
|
|
67
|
+
if (tpl.applied) { promptRes = { prompt: tpl.prompt, promptMeta: tpl.promptMeta }; }
|
|
68
|
+
// Council preset: expand a saved council into args.models (mutually exclusive with --models).
|
|
69
|
+
const hasModels = typeof args.models === 'string' && args.models.trim();
|
|
70
|
+
const hasCouncil = args.council !== undefined && args.council !== false;
|
|
71
|
+
if (hasModels && hasCouncil) {
|
|
72
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pass exactly one of --models / --council, not both' }));
|
|
73
|
+
}
|
|
74
|
+
if (!hasModels && !hasCouncil) {
|
|
75
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models is required (comma-separated aliases or provider/model IDs), or use --council <name>' }));
|
|
76
|
+
}
|
|
77
|
+
if (hasCouncil) {
|
|
78
|
+
if (typeof args.council !== 'string' || !args.council.trim()) {
|
|
79
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --council requires a council name (e.g. --council free)' }));
|
|
80
|
+
}
|
|
81
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
82
|
+
const { readCache } = require('./utils/model-catalog');
|
|
83
|
+
const catalog = (readCache() || {}).models || [];
|
|
84
|
+
const expanded = resolveCouncilMembers(args.council.trim(), catalog);
|
|
85
|
+
if (expanded.error) {
|
|
86
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }));
|
|
87
|
+
}
|
|
88
|
+
if (expanded.dropped && expanded.dropped.length && !useJson) {
|
|
89
|
+
process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
|
|
90
|
+
}
|
|
91
|
+
args.models = expanded.models.join(',');
|
|
92
|
+
}
|
|
93
|
+
if (args['wave-id']) {
|
|
94
|
+
const check = validateTaskId(String(args['wave-id']));
|
|
95
|
+
if (!check.valid) {
|
|
96
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (args.agent && String(args.agent).toLowerCase() === 'chat') {
|
|
100
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --agent chat is interactive-only; fanout is headless' }));
|
|
101
|
+
}
|
|
102
|
+
if (args.timeout !== undefined && args.timeout <= 0) {
|
|
103
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' }));
|
|
104
|
+
}
|
|
105
|
+
const mc = args['max-cost'];
|
|
106
|
+
if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
|
|
107
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
|
|
108
|
+
}
|
|
109
|
+
// v4.7 F8 (D13): reject-style (unlike sanitizeCouncilName, which cleans) —
|
|
110
|
+
// a stored tag is a user-chosen search key, so silent truncation/stripping
|
|
111
|
+
// would make --search/--group-by tag miss it.
|
|
112
|
+
if (args.tag !== undefined) {
|
|
113
|
+
const tagCheck = validateTag(args.tag);
|
|
114
|
+
if (!tagCheck.ok) {
|
|
115
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: tagCheck.error }));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const { parseModelsList } = require('./sidecar/fanout');
|
|
119
|
+
if (parseModelsList(args.models).length === 0) {
|
|
120
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models must contain at least one non-empty entry' }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Direct require (fanout stays internal — no src/index.js public re-export).
|
|
124
|
+
const { runFanout } = require('./sidecar/fanout');
|
|
125
|
+
const { loadConfig, resolveGatewayMode } = require('./utils/config');
|
|
126
|
+
const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
|
|
127
|
+
const { readCache } = require('./utils/model-catalog');
|
|
128
|
+
const cfg = loadConfig() || {};
|
|
129
|
+
const { exitCode } = await runFanout({
|
|
130
|
+
models: args.models,
|
|
131
|
+
prompt: promptRes.prompt,
|
|
132
|
+
promptMeta: promptRes.promptMeta,
|
|
133
|
+
waveId: args['wave-id'],
|
|
134
|
+
project: args.cwd || process.cwd(),
|
|
135
|
+
agent: args.agent || args.mode,
|
|
136
|
+
thinking: args.thinking,
|
|
137
|
+
timeout: args.timeout,
|
|
138
|
+
summaryLength: args['summary-length'],
|
|
139
|
+
includeContext: !args['no-context'],
|
|
140
|
+
sessionId: args['session-id'],
|
|
141
|
+
contextTurns: args['context-turns'],
|
|
142
|
+
contextSince: args['context-since'],
|
|
143
|
+
contextMaxTokens: args['context-max-tokens'],
|
|
144
|
+
// #10: forward the Cowork parent so MCP-spawned fanout legs pin the right
|
|
145
|
+
// session (mirrors handleStart's coworkProcess plumbing). Without this the
|
|
146
|
+
// spawned `--cowork-process` flag is dropped and buildContext gets null.
|
|
147
|
+
coworkProcess: args['cowork-process'],
|
|
148
|
+
mcp: args.mcp,
|
|
149
|
+
mcpConfig: args['mcp-config'],
|
|
150
|
+
noMcp: args['no-mcp'],
|
|
151
|
+
excludeMcp: args['exclude-mcp'],
|
|
152
|
+
noValidateModel: args['no-validate-model'],
|
|
153
|
+
// #61 Task 7.3: --gateway merged with routing.prefer, applied per leg
|
|
154
|
+
// by validateFanoutModels' router call.
|
|
155
|
+
gatewayMode: resolveGatewayMode(args.gateway),
|
|
156
|
+
json: !!args.json,
|
|
157
|
+
// v4.7 PR3 rider: `quiet` is a repo-wide known flag, so `fanout --quiet`
|
|
158
|
+
// parsed and exited 0 while runFanout still printed — forward it.
|
|
159
|
+
quiet: !!args.quiet,
|
|
160
|
+
client: args.client,
|
|
161
|
+
maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
|
|
162
|
+
noCostGate: !!args['no-cost-gate'],
|
|
163
|
+
maxCostPerMtok: cfg.maxCostPerMtok,
|
|
164
|
+
follow: !!args.follow,
|
|
165
|
+
onComplete: args['on-complete'],
|
|
166
|
+
// v4.3 Task 18 (spec §6.2): opt-in cheaper-model substitution. --fallback
|
|
167
|
+
// forces on, --no-fallback forces off; unset defers to config `fallbacks.enabled`.
|
|
168
|
+
fallback: resolveFallbackConfig({
|
|
169
|
+
flagFallback: args.fallback === true ? true : (args['no-fallback'] ? false : undefined),
|
|
170
|
+
config: cfg,
|
|
171
|
+
}),
|
|
172
|
+
catalog: (readCache() || {}).models || [],
|
|
173
|
+
pack: packRecord, // v4.5 Task 13: null when no --pack; additive on wave metadata.json + wave.json.
|
|
174
|
+
tag: args.tag, // v4.7 F8: undefined when no --tag; Task 3 stores it on wave metadata.
|
|
175
|
+
});
|
|
176
|
+
return exitCode;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { handleFanout };
|
package/src/cli-handlers-pack.js
CHANGED
|
@@ -23,7 +23,9 @@ const { ERROR_CODES, failJson } = require('./utils/error-doc');
|
|
|
23
23
|
/** Build a pack object from `pack save <name> --kind ... <flags>`. */
|
|
24
24
|
function buildPackFromFlags(name, args) {
|
|
25
25
|
const kind = args.kind;
|
|
26
|
-
|
|
26
|
+
// `--pack-version`, NOT `--version`: the latter is a global BOOLEAN_FLAG that
|
|
27
|
+
// bin/amicus.js intercepts before dispatch, so it could never arrive here.
|
|
28
|
+
const pack = { schemaVersion: 1, type: 'pack', name, version: args['pack-version'] || '1.0.0', kind };
|
|
27
29
|
if (args.description) { pack.description = String(args.description); }
|
|
28
30
|
if (kind === 'solo') { pack.model = args.model; }
|
|
29
31
|
else if (typeof args.bench === 'string') {
|
|
@@ -72,12 +74,18 @@ function packFromCouncilRun(name, version, run) {
|
|
|
72
74
|
};
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
/**
|
|
78
|
+
* wave branch: a fanout wave's metadata.json (type:'wave'), plus its first leg.
|
|
79
|
+
* `{fs, path, getSessionDir}` is threaded in from buildPackFromRun's own lazy
|
|
80
|
+
* require (below) rather than required again here — T14-m7: this file's other
|
|
81
|
+
* lazy requires (session-manager per cli-handlers-abort.js/cli-handlers-watch.js/
|
|
82
|
+
* mcp-council-awareness.js; pack-store/pack-validate in handlePack below) are
|
|
83
|
+
* all function-scoped, deferred until the `--from-run` path actually needs
|
|
84
|
+
* them, so a second copy of the same require triple here would just be
|
|
85
|
+
* duplication without buying back any additional laziness — buildPackFromRun
|
|
86
|
+
* already pays that cost once, on the only branch that reaches this function.
|
|
87
|
+
*/
|
|
88
|
+
function packFromWave(name, version, project, meta, { fs, path, getSessionDir }) {
|
|
81
89
|
const opts = {};
|
|
82
90
|
const firstLeg = Array.isArray(meta.legs) ? meta.legs[0] : null;
|
|
83
91
|
if (firstLeg) {
|
|
@@ -114,7 +122,7 @@ function packFromSolo(name, version, meta) {
|
|
|
114
122
|
*/
|
|
115
123
|
function buildPackFromRun(name, id, project, args) {
|
|
116
124
|
const runState = require('./council/run-state');
|
|
117
|
-
const version = args
|
|
125
|
+
const version = args['pack-version'] || '1.0.0'; // see buildPackFromFlags — never args.version
|
|
118
126
|
|
|
119
127
|
const ptr = runState.readPointer(project, id); // {runId, runDir}|null (run-state.js:156)
|
|
120
128
|
const run = ptr ? runState.readRun(ptr.runDir) : null;
|
|
@@ -128,7 +136,9 @@ function buildPackFromRun(name, id, project, args) {
|
|
|
128
136
|
let meta = null;
|
|
129
137
|
try { meta = JSON.parse(fs.readFileSync(path.join(getSessionDir(project, id), 'metadata.json'), 'utf-8')); }
|
|
130
138
|
catch { return { error: `Session ${id} not found` }; }
|
|
131
|
-
pack = meta.type === 'wave'
|
|
139
|
+
pack = meta.type === 'wave'
|
|
140
|
+
? packFromWave(name, version, project, meta, { fs, path, getSessionDir })
|
|
141
|
+
: packFromSolo(name, version, meta);
|
|
132
142
|
}
|
|
133
143
|
// T14-m4 (final-review): buildPackFromFlags honors --description (line ~27
|
|
134
144
|
// above); this --from-run path threaded `version` the same way but silently
|
|
@@ -152,7 +162,6 @@ function renderPackList(doc) {
|
|
|
152
162
|
});
|
|
153
163
|
text = 'Packs:\n' + lines.join('\n') + '\n';
|
|
154
164
|
}
|
|
155
|
-
for (const w of doc.warnings) { text += `Warning: ${w}\n`; }
|
|
156
165
|
return text;
|
|
157
166
|
}
|
|
158
167
|
|
|
@@ -201,6 +210,11 @@ async function handlePack(args) {
|
|
|
201
210
|
const { packs, warnings } = listPacks();
|
|
202
211
|
const doc = { schemaVersion: SCHEMA_VERSION, type: 'pack-list', dir: packsDir(), packs, warnings };
|
|
203
212
|
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderPackList(doc));
|
|
213
|
+
// T14-m1: warnings are diagnostics, not data — keep them off stdout so
|
|
214
|
+
// `amicus pack list | grep`/`--json` consumers never see them mixed into
|
|
215
|
+
// the list itself. `pack save` already writes this identical string to
|
|
216
|
+
// stderr (above); `--json` is untouched: `warnings` stays a field on doc.
|
|
217
|
+
if (!useJson) { for (const w of warnings) { process.stderr.write(`Warning: ${w}\n`); } }
|
|
204
218
|
return 0;
|
|
205
219
|
}
|
|
206
220
|
|