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.
Files changed (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +283 -0
  3. package/README.md +19 -7
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +143 -36
  6. package/docs/configuration.md +51 -3
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +59 -13
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +52 -14
  14. package/electron/workspace-ui/workspace-app.js +14 -3
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +6 -2
  19. package/electron/workspace-ui/workspace-seats.js +68 -0
  20. package/electron/workspace-ui/workspace.css +6 -0
  21. package/package.json +5 -2
  22. package/schemas/council-run.schema.json +1 -0
  23. package/schemas/council-stats.schema.json +9 -1
  24. package/schemas/run.schema.json +2 -1
  25. package/schemas/spend.schema.json +1 -1
  26. package/schemas/wave.schema.json +2 -1
  27. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  29. package/skills/sidecar/SKILL.md +7 -2
  30. package/src/cli-council-run-bench.js +86 -0
  31. package/src/cli-handlers-council-run.js +65 -81
  32. package/src/cli-handlers-council.js +17 -5
  33. package/src/cli-handlers-fanout.js +179 -0
  34. package/src/cli-handlers-pack.js +24 -10
  35. package/src/cli-handlers-run.js +19 -161
  36. package/src/cli-template-args.js +48 -0
  37. package/src/cli.js +39 -46
  38. package/src/council/debate.js +89 -10
  39. package/src/council/ledger.js +72 -11
  40. package/src/council/report.js +17 -6
  41. package/src/council/run-assemble.js +15 -3
  42. package/src/council/run-budget.js +2 -2
  43. package/src/council/run-chair.js +61 -5
  44. package/src/council/run-debate.js +51 -67
  45. package/src/council/run-launch.js +9 -2
  46. package/src/council/run-retry.js +4 -1
  47. package/src/council/run-stage1-launch.js +94 -0
  48. package/src/council/run-stage2.js +25 -4
  49. package/src/council/run-stages.js +79 -86
  50. package/src/council/run-state.js +10 -2
  51. package/src/council/run.js +26 -2
  52. package/src/council/tally.js +6 -2
  53. package/src/mcp-council-awareness.js +1 -0
  54. package/src/mcp-council-bench.js +4 -0
  55. package/src/mcp-council-run.js +10 -0
  56. package/src/mcp-server.js +114 -54
  57. package/src/mcp-tools.js +12 -5
  58. package/src/pack/pack-cli.js +1 -1
  59. package/src/pack/pack-forward.js +12 -4
  60. package/src/pack/pack-resolve.js +3 -0
  61. package/src/pack/pack-store.js +20 -3
  62. package/src/pack/pack-validate.js +5 -1
  63. package/src/sidecar/budget.js +38 -4
  64. package/src/sidecar/fanout-budget.js +1 -2
  65. package/src/sidecar/fanout-leg-fallback.js +7 -3
  66. package/src/sidecar/fanout-wave-io.js +13 -1
  67. package/src/sidecar/fanout.js +11 -9
  68. package/src/sidecar/list-limit.js +50 -0
  69. package/src/sidecar/list-search.js +69 -0
  70. package/src/sidecar/read.js +90 -5
  71. package/src/sidecar/start-metadata.js +58 -0
  72. package/src/sidecar/start.js +8 -43
  73. package/src/sidecar/workspace-auto-open.js +2 -2
  74. package/src/spend-query.js +2 -1
  75. package/src/template/apply.js +7 -4
  76. package/src/template/render.js +6 -2
  77. package/src/template/store.js +1 -1
  78. package/src/utils/cli-preflight.js +27 -1
  79. package/src/utils/config.js +15 -0
  80. package/src/utils/result-schema-rebuild.js +1 -0
  81. package/src/utils/result-schema.js +6 -1
  82. package/src/utils/session-index-tmp-sweep.js +18 -3
  83. package/src/utils/session-index.js +1 -0
  84. package/src/utils/session-metadata-tmp-sweep.js +24 -4
  85. package/src/utils/spend-ledger.js +11 -4
  86. 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
- if (args.template !== undefined) {
150
- const { applyTemplate } = require('./template/apply');
151
- const t = applyTemplate({ templateRef: args.template, prompt: promptRes.prompt,
152
- artifactFile: args.artifact, varList: args.var, project: args.cwd || process.cwd() });
153
- if (t.error) { return failJson(useJson, t.error); }
154
- for (const n of t.notices) { process.stderr.write(n + '\n'); }
155
- promptRes = { prompt: t.prompt, promptMeta: t.promptMeta };
156
- templateMeta = t.promptMeta.template;
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: `Error: --critic and --lenses are mutually exclusive in v4.0${packSuffix('critic') || packSuffix('lenses')}` });
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
- if (args.timeout !== undefined && args.timeout <= 0) {
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
- return 'model runs avg-cred confirm fact-err notes\n' +
64
- agg.map(a => `${a.model.padEnd(16)} ${String(a.runs).padStart(4)} ` +
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
- if (args.out !== undefined && (typeof args.out !== 'string' || args.out === '')) {
171
- return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: '-o/--out requires a value',
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 };
@@ -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
- const pack = { schemaVersion: 1, type: 'pack', name, version: args.version || '1.0.0', kind };
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
- /** wave branch: a fanout wave's metadata.json (type:'wave'), plus its first leg. */
76
- function packFromWave(name, version, project, meta) {
77
- const fs = require('fs');
78
- const path = require('path');
79
- const { getSessionDir } = require('./session-manager');
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.version || '1.0.0';
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' ? packFromWave(name, version, project, meta) : packFromSolo(name, version, meta);
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