amicus 4.6.2 → 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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  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 +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  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 +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
@@ -80,10 +80,14 @@ function realDeps() {
80
80
  now: () => Date.now(),
81
81
  listSessionIndexTmpFiles: () => tmpSweep.listSessionIndexTmpFiles(), // B15
82
82
  unlinkSessionIndexTmp: (n) => tmpSweep.unlinkSessionIndexTmp(n),
83
+ listSessionMetadataTmpFiles: () => metaSweep.listSessionMetadataTmpFiles(), // D8
84
+ unlinkSessionMetadataTmp: (n) => metaSweep.unlinkSessionMetadataTmp(n),
83
85
  };
84
86
  }
85
87
  // B15: sweep logic in utils/session-index-tmp-sweep.js (mirrors mcp-legacy's split).
86
88
  const tmpSweep = require('./utils/session-index-tmp-sweep');
89
+ // D8: per-session metadata.json sibling sweep — utils/session-metadata-tmp-sweep.js.
90
+ const metaSweep = require('./utils/session-metadata-tmp-sweep');
87
91
 
88
92
  /** Run one guarded check; a thrown fn becomes an error line. */
89
93
  function guard(id, name, fn) {
@@ -108,10 +112,10 @@ async function runDoctorChecks(depsOverride = {}) {
108
112
  const checks = [];
109
113
 
110
114
  checks.push(guard('node', 'Node.js', () => {
111
- const major = parseInt(String(d.nodeVersion).replace(/^v/, '').split('.')[0], 10);
112
- return major >= 18
115
+ const [maj, min] = String(d.nodeVersion).replace(/^v/, '').split('.').map(n => parseInt(n, 10));
116
+ return (maj > 22 || (maj === 22 && min >= 12))
113
117
  ? { id: 'node', name: 'Node.js', status: 'ok', message: d.nodeVersion, hint: null }
114
- : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=18)`, hint: 'Install Node 18 or newer from https://nodejs.org' };
118
+ : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=22.12)`, hint: 'Install Node 22.12 or newer from https://nodejs.org' };
115
119
  }));
116
120
 
117
121
  checks.push(guard('config-dir', 'Config directory', () => (
@@ -201,6 +205,8 @@ async function runDoctorChecks(depsOverride = {}) {
201
205
 
202
206
  checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
203
207
 
208
+ checks.push(guard('session-metadata-tmp', 'Session metadata tmp files', () => metaSweep.evaluateSessionMetadataTmpSweep(d)));
209
+
204
210
  // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
205
211
  checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
206
212
  const values = d.readApiKeyValues() || {};
@@ -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
 
@@ -13,11 +13,12 @@
13
13
  'use strict';
14
14
 
15
15
  const { validateStartArgs } = require('./cli');
16
- const { validateTaskId } = require('./utils/validators');
16
+ const { validateTaskId, validateTag } = require('./utils/validators');
17
17
  const { resolveLaunchModel, maybeOfferProviderDefaults } = require('./utils/start-helpers');
18
18
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
19
19
  const { requireNoUiForJson } = require('./utils/cli-preflight');
20
- const { GATEWAY_MODES } = require('./utils/model-descriptor');
20
+ const { handleFanout } = require('./cli-handlers-fanout');
21
+ const { applyTemplateForArgs } = require('./cli-template-args');
21
22
 
22
23
  /**
23
24
  * Handle 'sidecar start' command
@@ -37,17 +38,9 @@ async function handleStart(args) {
37
38
  // Drop --prompt-file post-resolve or validateStartArgs re-trips its XOR guard.
38
39
  delete args['prompt-file'];
39
40
  }
40
- if (args.template !== undefined) {
41
- const { applyTemplate } = require('./template/apply');
42
- const t = applyTemplate({ templateRef: args.template, prompt: args.prompt,
43
- artifactFile: args.artifact, varList: args.var, project: args.cwd || process.cwd() });
44
- if (t.error) { process.exit(failJson(useJson, t.error)); }
45
- for (const n of t.notices) { process.stderr.write(n + '\n'); }
46
- args.prompt = t.prompt;
47
- templateMeta = t.promptMeta.template;
48
- } else if (args.artifact !== undefined || args.var !== undefined) {
49
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --artifact/--var require --template (expansion happens only in template files)' }));
50
- }
41
+ const tpl = applyTemplateForArgs(args, args.prompt, useJson);
42
+ if (tpl.fail !== undefined) { process.exit(tpl.fail); }
43
+ if (tpl.applied) { args.prompt = tpl.prompt; templateMeta = tpl.templateMeta; }
51
44
  requireNoUiForJson(args, useJson);
52
45
 
53
46
  const mc = args['max-cost'];
@@ -55,6 +48,18 @@ async function handleStart(args) {
55
48
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
56
49
  }
57
50
 
51
+ // v4.7 F8 (D13): --tag rejects (unlike sanitizeCouncilName, which cleans) —
52
+ // a stored tag is a user-chosen search key, so silent truncation/stripping
53
+ // would make --search/--group-by tag miss it. Checked here, beside the other
54
+ // pre-flight arg checks, so a bad tag exits before model resolution / the
55
+ // one-time onboarding tip (T2-m1: was previously after resolveLaunchModel).
56
+ if (args.tag !== undefined) {
57
+ const tagCheck = validateTag(args.tag);
58
+ if (!tagCheck.ok) {
59
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: tagCheck.error }));
60
+ }
61
+ }
62
+
58
63
  const { model, alias } = await resolveLaunchModel(args);
59
64
  args.model = model;
60
65
 
@@ -118,157 +123,10 @@ async function handleStart(args) {
118
123
  modelInput: alias || null,
119
124
  template: templateMeta, // F9 (v4.5): startSidecar ignores unknown keys; inert until a future task reads it.
120
125
  pack: packRecord, // v4.5 Task 13: null when no --pack; additively recorded on solo session metadata.
126
+ tag: args.tag, // v4.7 F8: undefined when no --tag; Task 3 stores it on session metadata.
121
127
  });
122
128
  }
123
129
 
124
- /**
125
- * Handle 'amicus fanout' command (F4).
126
- * Returns the wave exit code: 0 all complete, 2 partial, 1 none/hard failure,
127
- * 130/143 when the wave was signal-aborted.
128
- */
129
- async function handleFanout(args) {
130
- const useJson = !!args.json;
131
-
132
- // --retry-failed <waveId> (v4.3 Task 19, spec 6.1): a completely different
133
- // path from the --prompt/--models launch below (no briefing, no required
134
- // --models — the original wave's failed legs supply their own saved
135
- // context) — dispatch BEFORE any of that validation runs. --models here is
136
- // optional and, when present, filters which failed legs get retried.
137
- if (args['retry-failed']) {
138
- const { retryFailedWave } = require('./sidecar/fanout-retry');
139
- const { parseModelsList } = require('./sidecar/fanout-validate');
140
- const { exitCode, errorDoc } = await retryFailedWave(String(args['retry-failed']), args.cwd || process.cwd(), {
141
- models: parseModelsList(args.models), json: useJson,
142
- });
143
- if (errorDoc && useJson) { process.stdout.write(JSON.stringify(errorDoc) + '\n'); }
144
- return exitCode;
145
- }
146
- const packRecord = require('./pack/pack-cli').applyPackOrExit(args, 'fanout', useJson);
147
-
148
- // FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
149
- // --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
150
- // value silently fell through to resolveGatewayMode's pass-through instead
151
- // of failing fast with a clear error.
152
- if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
153
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
154
- }
155
-
156
- const { resolvePromptSource } = require('./utils/prompt-source');
157
- let promptRes;
158
- if (args.prompt !== undefined || args['prompt-file'] !== undefined || args.template === undefined) {
159
- promptRes = resolvePromptSource(args);
160
- if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
161
- } else {
162
- promptRes = { prompt: undefined, promptMeta: null };
163
- }
164
- if (args.template !== undefined) {
165
- const { applyTemplate } = require('./template/apply');
166
- const t = applyTemplate({ templateRef: args.template, prompt: promptRes.prompt,
167
- artifactFile: args.artifact, varList: args.var, project: args.cwd || process.cwd() });
168
- if (t.error) { process.exit(failJson(useJson, t.error)); }
169
- for (const n of t.notices) { process.stderr.write(n + '\n'); }
170
- promptRes = { prompt: t.prompt, promptMeta: t.promptMeta };
171
- } else if (args.artifact !== undefined || args.var !== undefined) {
172
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --artifact/--var require --template (expansion happens only in template files)' }));
173
- }
174
- // Council preset: expand a saved council into args.models (mutually exclusive with --models).
175
- const hasModels = typeof args.models === 'string' && args.models.trim();
176
- const hasCouncil = args.council !== undefined && args.council !== false;
177
- if (hasModels && hasCouncil) {
178
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pass exactly one of --models / --council, not both' }));
179
- }
180
- if (!hasModels && !hasCouncil) {
181
- 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>' }));
182
- }
183
- if (hasCouncil) {
184
- if (typeof args.council !== 'string' || !args.council.trim()) {
185
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --council requires a council name (e.g. --council free)' }));
186
- }
187
- const { resolveCouncilMembers } = require('./utils/config');
188
- const { readCache } = require('./utils/model-catalog');
189
- const catalog = (readCache() || {}).models || [];
190
- const expanded = resolveCouncilMembers(args.council.trim(), catalog);
191
- if (expanded.error) {
192
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }));
193
- }
194
- if (expanded.dropped && expanded.dropped.length && !useJson) {
195
- process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
196
- }
197
- args.models = expanded.models.join(',');
198
- }
199
- if (args['wave-id']) {
200
- const check = validateTaskId(String(args['wave-id']));
201
- if (!check.valid) {
202
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
203
- }
204
- }
205
- if (args.agent && String(args.agent).toLowerCase() === 'chat') {
206
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --agent chat is interactive-only; fanout is headless' }));
207
- }
208
- if (args.timeout !== undefined && args.timeout <= 0) {
209
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' }));
210
- }
211
- const mc = args['max-cost'];
212
- if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
213
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
214
- }
215
- const { parseModelsList } = require('./sidecar/fanout');
216
- if (parseModelsList(args.models).length === 0) {
217
- process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models must contain at least one non-empty entry' }));
218
- }
219
-
220
- // Direct require (fanout stays internal — no src/index.js public re-export).
221
- const { runFanout } = require('./sidecar/fanout');
222
- const { loadConfig, resolveGatewayMode } = require('./utils/config');
223
- const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
224
- const { readCache } = require('./utils/model-catalog');
225
- const cfg = loadConfig() || {};
226
- const { exitCode } = await runFanout({
227
- models: args.models,
228
- prompt: promptRes.prompt,
229
- promptMeta: promptRes.promptMeta,
230
- waveId: args['wave-id'],
231
- project: args.cwd || process.cwd(),
232
- agent: args.agent || args.mode,
233
- thinking: args.thinking,
234
- timeout: args.timeout,
235
- summaryLength: args['summary-length'],
236
- includeContext: !args['no-context'],
237
- sessionId: args['session-id'],
238
- contextTurns: args['context-turns'],
239
- contextSince: args['context-since'],
240
- contextMaxTokens: args['context-max-tokens'],
241
- // #10: forward the Cowork parent so MCP-spawned fanout legs pin the right
242
- // session (mirrors handleStart's coworkProcess plumbing). Without this the
243
- // spawned `--cowork-process` flag is dropped and buildContext gets null.
244
- coworkProcess: args['cowork-process'],
245
- mcp: args.mcp,
246
- mcpConfig: args['mcp-config'],
247
- noMcp: args['no-mcp'],
248
- excludeMcp: args['exclude-mcp'],
249
- noValidateModel: args['no-validate-model'],
250
- // #61 Task 7.3: --gateway merged with routing.prefer, applied per leg
251
- // by validateFanoutModels' router call.
252
- gatewayMode: resolveGatewayMode(args.gateway),
253
- json: !!args.json,
254
- client: args.client,
255
- maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
256
- noCostGate: !!args['no-cost-gate'],
257
- maxCostPerMtok: cfg.maxCostPerMtok,
258
- follow: !!args.follow,
259
- onComplete: args['on-complete'],
260
- // v4.3 Task 18 (spec §6.2): opt-in cheaper-model substitution. --fallback
261
- // forces on, --no-fallback forces off; unset defers to config `fallbacks.enabled`.
262
- fallback: resolveFallbackConfig({
263
- flagFallback: args.fallback === true ? true : (args['no-fallback'] ? false : undefined),
264
- config: cfg,
265
- }),
266
- catalog: (readCache() || {}).models || [],
267
- pack: packRecord, // v4.5 Task 13: null when no --pack; additive on wave metadata.json + wave.json.
268
- });
269
- return exitCode;
270
- }
271
-
272
130
  /**
273
131
  * Handle 'sidecar read' command
274
132
  * Spec Reference: §4.5
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module cli-template-args
5
+ * v4.7 PR6 sweep: the single application point for --template/--artifact/--var,
6
+ * shared by the three CLI handlers (handleStart, handleFanout, handleCouncilRun)
7
+ * that used to carry this block verbatim-triplicated.
8
+ *
9
+ * NEVER calls process.exit: handleStart and handleFanout exit on failure, but
10
+ * handleCouncilRun RETURNS its exit code (its whole handler contract is
11
+ * return-the-code) — so the decision belongs to the caller, not this helper.
12
+ */
13
+
14
+ const { failJson, ERROR_CODES } = require('./utils/error-doc');
15
+
16
+ const NEEDS_TEMPLATE_MSG =
17
+ 'Error: --artifact/--var require --template (expansion happens only in template files)';
18
+
19
+ /**
20
+ * @param {object} args parsed argv
21
+ * @param {string|undefined} prompt pre-template prompt text
22
+ * @param {boolean} useJson
23
+ * @returns {{applied:false}
24
+ * | {applied:true, prompt:string, promptMeta:object, templateMeta:object}
25
+ * | {fail:number}}
26
+ */
27
+ function applyTemplateForArgs(args, prompt, useJson) {
28
+ if (args.template !== undefined) {
29
+ const { applyTemplate } = require('./template/apply');
30
+ const t = applyTemplate({
31
+ templateRef: args.template, prompt,
32
+ artifactFile: args.artifact, varList: args.var,
33
+ project: args.cwd || process.cwd(),
34
+ });
35
+ if (t.error) { return { fail: failJson(useJson, t.error) }; }
36
+ for (const n of t.notices) { process.stderr.write(n + '\n'); }
37
+ return {
38
+ applied: true, prompt: t.prompt, promptMeta: t.promptMeta,
39
+ templateMeta: t.promptMeta.template,
40
+ };
41
+ }
42
+ if (args.artifact !== undefined || args.var !== undefined) {
43
+ return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: NEEDS_TEMPLATE_MSG }) };
44
+ }
45
+ return { applied: false };
46
+ }
47
+
48
+ module.exports = { applyTemplateForArgs, NEEDS_TEMPLATE_MSG };