amicus 4.4.1 → 4.5.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 (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +130 -0
  3. package/README.md +15 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/ROADMAP.md +36 -10
  6. package/docs/configuration.md +24 -0
  7. package/docs/council.md +59 -0
  8. package/docs/schemas.md +1 -0
  9. package/docs/usage.md +151 -1
  10. package/electron/workspace-ui/workspace-app.js +39 -17
  11. package/electron/workspace-ui/workspace-panels.js +76 -18
  12. package/electron/workspace-ui/workspace-render.js +10 -0
  13. package/package.json +1 -1
  14. package/schemas/council-run-live.schema.json +1 -1
  15. package/schemas/council-run.schema.json +14 -0
  16. package/schemas/error.schema.json +1 -1
  17. package/schemas/event.schema.json +1 -1
  18. package/schemas/pack.schema.json +30 -0
  19. package/schemas/progress.schema.json +1 -1
  20. package/schemas/run-live.schema.json +1 -1
  21. package/schemas/run.schema.json +2 -1
  22. package/schemas/wave-live.schema.json +1 -1
  23. package/schemas/wave.schema.json +2 -1
  24. package/skills/second-opinion/SKILL.md +5 -0
  25. package/src/cli-handlers-council-run.js +51 -8
  26. package/src/cli-handlers-pack.js +238 -0
  27. package/src/cli-handlers-run.js +36 -8
  28. package/src/cli-handlers-template.js +53 -0
  29. package/src/cli.js +64 -3
  30. package/src/council/findings.js +4 -41
  31. package/src/council/presets-cli.js +23 -11
  32. package/src/council/run-stages.js +12 -9
  33. package/src/council/run-state.js +17 -0
  34. package/src/council/run.js +1 -1
  35. package/src/headless.js +18 -14
  36. package/src/mcp-council-run.js +108 -4
  37. package/src/mcp-server.js +203 -7
  38. package/src/mcp-tools.js +15 -5
  39. package/src/pack/pack-cli.js +38 -0
  40. package/src/pack/pack-forward.js +96 -0
  41. package/src/pack/pack-resolve.js +297 -0
  42. package/src/pack/pack-store.js +130 -0
  43. package/src/pack/pack-validate.js +113 -0
  44. package/src/sidecar/fanout.js +21 -4
  45. package/src/sidecar/progress.js +34 -0
  46. package/src/sidecar/start.js +5 -4
  47. package/src/sidecar/workspace-auto-open.js +69 -0
  48. package/src/sidecar/workspace-window.js +46 -1
  49. package/src/template/apply.js +88 -0
  50. package/src/template/render.js +86 -0
  51. package/src/template/store.js +106 -0
  52. package/src/utils/config.js +65 -25
  53. package/src/utils/error-doc.js +5 -0
  54. package/src/utils/result-schema-rebuild.js +1 -0
  55. package/src/utils/result-schema.js +8 -2
  56. package/src/workspace/artifact-guard.js +44 -6
  57. package/src/workspace/run-detail.js +6 -0
@@ -211,6 +211,11 @@ mangled alias (instant arg-parse failure). For a free council, swap `--models "<
211
211
  `--council free`. Run it in the background (`run_in_background: true`); you are notified on
212
212
  completion — do not poll.
213
213
 
214
+ **Saved run configs (v4.5).** Every flag above this line can also come from a saved
215
+ [policy pack](../../docs/usage.md#policy-packs) — `--pack <name>` loads its bench/chair/critic/
216
+ lenses/options/template as defaults (an explicit flag still overrides it), the same on the
217
+ `amicus_council_run`/`amicus_fanout`/`amicus_start` MCP tools' `pack` param.
218
+
214
219
  **Budget gate — one flag for the whole run.** By default the gate refuses any leg whose price
215
220
  exceeds the per-$/Mtok threshold (the o3/o3-pro guard). To run an intentionally expensive model the
216
221
  user asked for by name, pass `--no-cost-gate`; to raise only the total ceiling, pass
@@ -71,9 +71,11 @@ function resolveBench(args, useJson) {
71
71
  if (expanded.dropped && expanded.dropped.length && !useJson) {
72
72
  process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
73
73
  }
74
- return { bench: expanded.models, presetName };
74
+ // v4.5 Wave 2: threaded into runCouncil's options — the ONLY prior signal
75
+ // was the stderr-only Notice above, which --json mode never even prints.
76
+ return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
75
77
  }
76
- return { bench: parseList(args.models), presetName: null };
78
+ return { bench: parseList(args.models), presetName: null, droppedMembers: [] };
77
79
  }
78
80
 
79
81
  function renderRunHuman(run) {
@@ -115,6 +117,27 @@ function renderRunHuman(run) {
115
117
  async function handleCouncilRun(args) {
116
118
  const useJson = !!args.json;
117
119
 
120
+ // v4.5 Task 12 (B7/F5): resolve --pack FIRST, above the Task-5 template
121
+ // block, so a pack-filled args.template renders through that single
122
+ // existing application point exactly like a typed --template.
123
+ let packRecord = null;
124
+ const explicitKeys = args.__explicit || new Set();
125
+ if (args.pack !== undefined) {
126
+ const { applyPackToArgs } = require('./pack/pack-resolve');
127
+ const pr = applyPackToArgs({
128
+ packRef: args.pack, expectedKind: 'council', args,
129
+ explicit: explicitKeys, useJson,
130
+ });
131
+ if (pr.error) { return failJson(useJson, pr.error); }
132
+ for (const n of pr.notices) { process.stderr.write(n + '\n'); }
133
+ packRecord = pr.packRecord;
134
+ }
135
+ // 2026-07-28 ruling (Task-11 review): attribute a pre-flight failure to the
136
+ // pack that supplied the failing value — ONLY when the pack filled it (an
137
+ // explicit flag always wins and is never "blamed" on the pack).
138
+ const packSuffix = (key) => (packRecord && args[key] !== undefined && !explicitKeys.has(key))
139
+ ? ` (set by pack '${packRecord.name}')` : '';
140
+
118
141
  // --prompt-file required; inline --prompt rejected (councils always have
119
142
  // real briefings — same rationale as MCP fanout's briefing-via-file).
120
143
  if (args.prompt !== undefined) {
@@ -123,9 +146,26 @@ async function handleCouncilRun(args) {
123
146
  hint: 'write the briefing to a file and pass --prompt-file <path>' });
124
147
  }
125
148
  const { resolvePromptSource } = require('./utils/prompt-source');
126
- const promptRes = resolvePromptSource(args);
127
- if (promptRes.error) {
128
- return failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error });
149
+ // F9 (v4.5): with --template and no {{prompt}} slot, --prompt-file may be
150
+ // absent (mirrors handleFanout's guard); byte-identical without --template.
151
+ let promptRes;
152
+ if (args.prompt !== undefined || args['prompt-file'] !== undefined || args.template === undefined) {
153
+ promptRes = resolvePromptSource(args);
154
+ if (promptRes.error) { return failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }); }
155
+ } else {
156
+ promptRes = { prompt: undefined, promptMeta: null };
157
+ }
158
+ let templateMeta = null;
159
+ if (args.template !== undefined) {
160
+ const { applyTemplate } = require('./template/apply');
161
+ const t = applyTemplate({ templateRef: args.template, prompt: promptRes.prompt,
162
+ artifactFile: args.artifact, varList: args.var, project: args.cwd || process.cwd() });
163
+ if (t.error) { return failJson(useJson, t.error); }
164
+ for (const n of t.notices) { process.stderr.write(n + '\n'); }
165
+ promptRes = { prompt: t.prompt, promptMeta: t.promptMeta };
166
+ templateMeta = t.promptMeta.template;
167
+ } else if (args.artifact !== undefined || args.var !== undefined) {
168
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --artifact/--var require --template (expansion happens only in template files)' });
129
169
  }
130
170
 
131
171
  const benchRes = resolveBench(args, useJson);
@@ -153,19 +193,19 @@ async function handleCouncilRun(args) {
153
193
  ? args.chair.trim() : CHAIR_DEFAULT;
154
194
  if (bench.includes(chair)) {
155
195
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
156
- message: `Error: chair '${chair}' is a bench seat — the chair must not review`,
196
+ message: `Error: chair '${chair}' is a bench seat — the chair must not review${packSuffix('chair')}`,
157
197
  hint: `pick a chair outside --models (default: ${CHAIR_DEFAULT}), or remove '${chair}' from the bench` });
158
198
  }
159
199
  const critic = (typeof args.critic === 'string' && args.critic.trim()) ? args.critic.trim() : null;
160
200
  if (critic && !bench.includes(critic)) {
161
201
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
162
- message: `Error: critic '${critic}' must be one of the bench seats`,
202
+ message: `Error: critic '${critic}' must be one of the bench seats${packSuffix('critic')}`,
163
203
  hint: `--critic swaps one seat's brief; pass one of: ${bench.join(', ')}` });
164
204
  }
165
205
  const lenses = (typeof args.lenses === 'string' && args.lenses.trim()) ? parseList(args.lenses) : null;
166
206
  if (critic && lenses) {
167
207
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
168
- message: 'Error: --critic and --lenses are mutually exclusive in v4.0' });
208
+ message: `Error: --critic and --lenses are mutually exclusive in v4.0${packSuffix('critic') || packSuffix('lenses')}` });
169
209
  }
170
210
  if (lenses && lenses.length !== bench.length) {
171
211
  return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
@@ -211,6 +251,9 @@ async function handleCouncilRun(args) {
211
251
  noValidateModel: !!args['no-validate-model'],
212
252
  date: new Date().toISOString().slice(0, 10),
213
253
  councilName,
254
+ template: templateMeta, // F9 (v4.5): null when no --template; additive on the run.json seed (run-state.js).
255
+ pack: packRecord, // v4.5 Task 12 (B7/F5): null when no --pack; additive on the run.json seed (run-state.js).
256
+ droppedMembers: benchRes.droppedMembers, // v4.5 Wave 2: [] when nothing dropped; additive on the run.json seed (run-state.js).
214
257
  // v4.1 §4.5b/§4.5d. `--claude-review` is resolved here but VALIDATED by the
215
258
  // engine's preflightClaudeReview (run-assemble.js): the reserved-seat and
216
259
  // 'claude may not chair' guards live there on purpose so MCP, the GitHub
@@ -0,0 +1,238 @@
1
+ // src/cli-handlers-pack.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module cli-handlers-pack
6
+ * F5/B7 (v4.5), Task 14: `amicus pack save|list|show|rm` + `--from-run`. The
7
+ * user-facing management CLI on top of the pack subsystem built by Tasks 7-13
8
+ * (pack-store, pack-validate, pack-resolve, --pack on the three run commands).
9
+ * Idiom donor: src/cli-handlers-template.js (doc object -> useJson ?
10
+ * JSON.stringify : render) and src/council/presets-cli.js (list/show render
11
+ * shape, `amicus council show`'s diagnostic-mirror style for `pack show`).
12
+ *
13
+ * `pack save <name> --from-run <id>` builds a pack from an existing council
14
+ * run / fanout wave / solo session instead of flags (buildPackFromRun below).
15
+ * Resolution order: council pointer (run-state.js) -> wave metadata.json ->
16
+ * solo metadata.json. Briefing TEXT is never captured, only a template
17
+ * reference when the source run recorded one.
18
+ */
19
+
20
+ const { SCHEMA_VERSION } = require('./utils/result-schema');
21
+ const { ERROR_CODES, failJson } = require('./utils/error-doc');
22
+
23
+ /** Build a pack object from `pack save <name> --kind ... <flags>`. */
24
+ function buildPackFromFlags(name, args) {
25
+ const kind = args.kind;
26
+ const pack = { schemaVersion: 1, type: 'pack', name, version: args.version || '1.0.0', kind };
27
+ if (args.description) { pack.description = String(args.description); }
28
+ if (kind === 'solo') { pack.model = args.model; }
29
+ else if (typeof args.bench === 'string') {
30
+ // A comma present means an explicit member list; a single comma-less
31
+ // value is a council/bench NAME string (validatePack {mode:'save'} is
32
+ // the net that catches an unresolvable one of either form).
33
+ pack.bench = args.bench.includes(',')
34
+ ? args.bench.split(',').map((s) => s.trim()).filter(Boolean)
35
+ : args.bench.trim();
36
+ }
37
+ if (kind === 'council') {
38
+ if (args.chair) { pack.chair = args.chair; }
39
+ if (args.critic) { pack.critic = args.critic; }
40
+ if (typeof args.lenses === 'string') { pack.lenses = args.lenses.split(',').map((s) => s.trim()).filter(Boolean); }
41
+ }
42
+ const opts = {};
43
+ if (args.__explicit.has('timeout')) { opts.timeout = args.timeout; }
44
+ if (args['max-cost'] !== undefined) { opts.maxCost = args['max-cost']; }
45
+ if (args.gateway) { opts.gateway = args.gateway; }
46
+ if (args.agent) { opts.agent = args.agent; }
47
+ if (args.thinking) { opts.thinking = args.thinking; }
48
+ if (args.__explicit.has('summary-length')) { opts.summaryLength = args['summary-length']; }
49
+ // Task-10 ruling (pack-resolve.js:116): a negatable boolean's "was this
50
+ // explicit" check must test BOTH forms, since __explicit only records the
51
+ // literal typed key (--no-debate never sets args.debate to anything).
52
+ if (args.__explicit.has('debate') || args.__explicit.has('no-debate')) { opts.debate = !!args.debate; }
53
+ if (Object.keys(opts).length) { pack.options = opts; }
54
+ if (args.template) { pack.briefing = { template: String(args.template) }; }
55
+ return pack;
56
+ }
57
+
58
+ /** council branch of buildPackFromRun: a run.json reached via the council pointer. */
59
+ function packFromCouncilRun(name, version, run) {
60
+ const opts = {};
61
+ if (run.options && run.options.timeout) { opts.timeout = run.options.timeout; }
62
+ if (run.options && run.options.maxCost !== undefined && run.options.maxCost !== null) { opts.maxCost = run.options.maxCost; }
63
+ if (run.options && run.options.gateway) { opts.gateway = run.options.gateway; }
64
+ if (run.debate && run.debate.enabled) { opts.debate = true; }
65
+ return {
66
+ schemaVersion: 1, type: 'pack', name, version, kind: 'council',
67
+ bench: run.bench.slice(), chair: run.chair,
68
+ ...(run.critic ? { critic: run.critic } : {}),
69
+ ...(run.lenses ? { lenses: run.lenses.slice() } : {}),
70
+ ...(Object.keys(opts).length ? { options: opts } : {}),
71
+ ...(run.template ? { briefing: { template: run.template.name } } : {}),
72
+ };
73
+ }
74
+
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
+
81
+ const opts = {};
82
+ const firstLeg = Array.isArray(meta.legs) ? meta.legs[0] : null;
83
+ if (firstLeg) {
84
+ try {
85
+ const legMeta = JSON.parse(fs.readFileSync(path.join(getSessionDir(project, firstLeg), 'metadata.json'), 'utf-8'));
86
+ if (legMeta.agent) { opts.agent = legMeta.agent; }
87
+ if (legMeta.thinking) { opts.thinking = legMeta.thinking; }
88
+ } catch { /* leg metadata unreadable/absent — omit, never null-stuff */ }
89
+ }
90
+ return {
91
+ schemaVersion: 1, type: 'pack', name, version, kind: 'fanout',
92
+ bench: meta.models.slice(),
93
+ ...(Object.keys(opts).length ? { options: opts } : {}),
94
+ ...(meta.promptMeta && meta.promptMeta.template ? { briefing: { template: meta.promptMeta.template.name } } : {}),
95
+ };
96
+ }
97
+
98
+ /** solo branch: an ordinary (non-wave) session's metadata.json. */
99
+ function packFromSolo(name, version, meta) {
100
+ const opts = {};
101
+ if (meta.agent) { opts.agent = meta.agent; }
102
+ if (meta.thinking) { opts.thinking = meta.thinking; }
103
+ return {
104
+ schemaVersion: 1, type: 'pack', name, version, kind: 'solo',
105
+ model: meta.model,
106
+ ...(Object.keys(opts).length ? { options: opts } : {}),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Build a pack object from `pack save <name> --from-run <id>`. Resolution
112
+ * order: council pointer/run.json -> wave metadata.json -> solo metadata.json.
113
+ * @returns {object} a pack object, or `{error: string}` when `id` resolves to nothing.
114
+ */
115
+ function buildPackFromRun(name, id, project, args) {
116
+ const runState = require('./council/run-state');
117
+ const version = args.version || '1.0.0';
118
+
119
+ const ptr = runState.readPointer(project, id); // {runId, runDir}|null (run-state.js:156)
120
+ const run = ptr ? runState.readRun(ptr.runDir) : null;
121
+ let pack;
122
+ if (run && run.type === 'council-run') {
123
+ pack = packFromCouncilRun(name, version, run);
124
+ } else {
125
+ const fs = require('fs');
126
+ const path = require('path');
127
+ const { getSessionDir } = require('./session-manager');
128
+ let meta = null;
129
+ try { meta = JSON.parse(fs.readFileSync(path.join(getSessionDir(project, id), 'metadata.json'), 'utf-8')); }
130
+ catch { return { error: `Session ${id} not found` }; }
131
+ pack = meta.type === 'wave' ? packFromWave(name, version, project, meta) : packFromSolo(name, version, meta);
132
+ }
133
+ // T14-m4 (final-review): buildPackFromFlags honors --description (line ~27
134
+ // above); this --from-run path threaded `version` the same way but silently
135
+ // dropped --description. Honor it here too, regardless of which branch
136
+ // (council/wave/solo) produced the pack.
137
+ if (args.description) { pack.description = String(args.description); }
138
+ return pack;
139
+ }
140
+
141
+ function renderPackList(doc) {
142
+ let text = '';
143
+ if (doc.packs.length === 0) {
144
+ text = 'No packs.\n';
145
+ } else {
146
+ const lines = doc.packs.map((p) => {
147
+ const name = String(p.name || '(unnamed)').padEnd(20);
148
+ const kind = String(p.kind || '(unknown)');
149
+ const version = String(p.version || '0.0.0');
150
+ const desc = p.description ? ` — ${p.description}` : '';
151
+ return ` ${name} [${kind}] v${version}${desc}`;
152
+ });
153
+ text = 'Packs:\n' + lines.join('\n') + '\n';
154
+ }
155
+ for (const w of doc.warnings) { text += `Warning: ${w}\n`; }
156
+ return text;
157
+ }
158
+
159
+ function renderPackShow(doc) {
160
+ const { pack, path: file, source, hash, validation } = doc;
161
+ const lines = [`Pack '${pack.name}' v${pack.version} [${pack.kind}] (${source}: ${file})`, ` hash: ${hash}`];
162
+ if (pack.description) { lines.push(` description: ${pack.description}`); }
163
+ if (pack.kind === 'solo') { lines.push(` model: ${pack.model}`); }
164
+ else { lines.push(` bench: ${Array.isArray(pack.bench) ? pack.bench.join(', ') : pack.bench}`); }
165
+ if (pack.chair) { lines.push(` chair: ${pack.chair}`); }
166
+ if (pack.critic) { lines.push(` critic: ${pack.critic}`); }
167
+ if (pack.lenses) { lines.push(` lenses: ${pack.lenses.join(', ')}`); }
168
+ if (pack.options && Object.keys(pack.options).length) { lines.push(` options: ${JSON.stringify(pack.options)}`); }
169
+ if (pack.briefing && pack.briefing.template) { lines.push(` briefing.template: ${pack.briefing.template}`); }
170
+ lines.push(validation.ok ? ' validation: ok' : ` validation: INVALID — ${validation.errors.join('; ')}`);
171
+ if (validation.ok && validation.warnings.length) { lines.push(` warnings: ${validation.warnings.join('; ')}`); }
172
+ return lines.join('\n') + '\n';
173
+ }
174
+
175
+ /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
176
+ async function handlePack(args) {
177
+ const useJson = !!args.json;
178
+ const sub = args._[1];
179
+ const { readPack, writePack, listPacks, rmPack, packsDir } = require('./pack/pack-store');
180
+ const { validatePack } = require('./pack/pack-validate');
181
+
182
+ if (sub === 'save') {
183
+ const name = args._[2];
184
+ if (!name) { return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pack save needs a <name>' }); }
185
+ const pack = args['from-run']
186
+ ? buildPackFromRun(name, String(args['from-run']), args.cwd || process.cwd(), args)
187
+ : buildPackFromFlags(name, args);
188
+ if (pack.error) { return failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: `Error: ${pack.error}` }); }
189
+ const v = validatePack(pack, { mode: 'save' });
190
+ if (!v.ok) { return failJson(useJson, { code: ERROR_CODES.PACK_INVALID, message: `Error: invalid pack: ${v.errors.join('; ')}` }); }
191
+ for (const w of v.warnings) { process.stderr.write(`Warning: ${w}\n`); }
192
+ const res = writePack(pack);
193
+ const doc = { schemaVersion: SCHEMA_VERSION, type: 'pack-save', name, ...res };
194
+ process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n'
195
+ : (res.noop ? `Pack '${name}' unchanged (no-op).\n`
196
+ : `Saved pack '${name}' v${res.version}${res.bumped ? ' (version auto-bumped)' : ''} → ${res.path}\n`));
197
+ return 0;
198
+ }
199
+
200
+ if (sub === 'list') {
201
+ const { packs, warnings } = listPacks();
202
+ const doc = { schemaVersion: SCHEMA_VERSION, type: 'pack-list', dir: packsDir(), packs, warnings };
203
+ process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderPackList(doc));
204
+ return 0;
205
+ }
206
+
207
+ if (sub === 'show') {
208
+ const ref = args._[2];
209
+ if (!ref) { return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pack show needs a <name|path>' }); }
210
+ const r = readPack(ref);
211
+ if (r.error) { return failJson(useJson, { code: ERROR_CODES.PACK_NOT_FOUND, message: r.error, hint: 'amicus pack list' }); }
212
+ // {mode:'save'} semantics per spec §5.3: reports, never fails — the
213
+ // `council show` diagnostic mirror. Run-time-only checks (e.g. an
214
+ // unresolvable briefing.template) are demoted to a warning in this mode.
215
+ const validation = validatePack(r.pack, { mode: 'save' });
216
+ const doc = { schemaVersion: SCHEMA_VERSION, type: 'pack-show', ...r, validation };
217
+ process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderPackShow(doc));
218
+ return 0;
219
+ }
220
+
221
+ if (sub === 'rm') {
222
+ const name = args._[2];
223
+ if (!name) { return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pack rm needs a <name>' }); }
224
+ const res = rmPack(name);
225
+ // v4.5 HOLD-gate decision 3: unified with `pack show`'s missing-pack code
226
+ // (same hint idiom) — was BAD_ARGS, so a script switching on error.code got
227
+ // two different answers for the identical "no such pack" condition.
228
+ if (!res.removed) { return failJson(useJson, { code: ERROR_CODES.PACK_NOT_FOUND, message: `Error: pack '${name}' not found`, hint: 'amicus pack list' }); }
229
+ const doc = { schemaVersion: SCHEMA_VERSION, type: 'pack-rm', name, removed: true };
230
+ process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : `Removed pack '${name}'.\n`);
231
+ return 0;
232
+ }
233
+
234
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
235
+ message: `Error: unknown pack subcommand '${sub || ''}'`, hint: 'amicus pack save|list|show|rm' });
236
+ }
237
+
238
+ module.exports = { handlePack };
@@ -25,18 +25,29 @@ const { GATEWAY_MODES } = require('./utils/model-descriptor');
25
25
  */
26
26
  async function handleStart(args) {
27
27
  const useJson = !!args.json;
28
-
28
+ const packRecord = require('./pack/pack-cli').applyPackOrExit(args, 'solo', useJson);
29
29
  // F4: --prompt-file support (XOR --prompt) and --json gating
30
+ // F9 (v4.5): --template renders {{prompt}}/{{artifact}}/{{var.*}} into the prompt; byte-identical without it.
31
+ let templateMeta = null;
30
32
  if (args.prompt !== undefined || args['prompt-file'] !== undefined) {
31
33
  const { resolvePromptSource } = require('./utils/prompt-source');
32
34
  const promptRes = resolvePromptSource(args);
33
35
  if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
34
36
  args.prompt = promptRes.prompt;
35
- // Drop --prompt-file now that it's resolved: validateStartArgs' self-contained
36
- // guard would otherwise re-run resolvePromptSource with both prompt and
37
- // prompt-file set and trip its mutually-exclusive branch.
37
+ // Drop --prompt-file post-resolve or validateStartArgs re-trips its XOR guard.
38
38
  delete args['prompt-file'];
39
39
  }
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
+ }
40
51
  requireNoUiForJson(args, useJson);
41
52
 
42
53
  const mc = args['max-cost'];
@@ -105,6 +116,8 @@ async function handleStart(args) {
105
116
  position: args.position,
106
117
  json: !!args.json,
107
118
  modelInput: alias || null,
119
+ template: templateMeta, // F9 (v4.5): startSidecar ignores unknown keys; inert until a future task reads it.
120
+ pack: packRecord, // v4.5 Task 13: null when no --pack; additively recorded on solo session metadata.
108
121
  });
109
122
  }
110
123
 
@@ -130,6 +143,7 @@ async function handleFanout(args) {
130
143
  if (errorDoc && useJson) { process.stdout.write(JSON.stringify(errorDoc) + '\n'); }
131
144
  return exitCode;
132
145
  }
146
+ const packRecord = require('./pack/pack-cli').applyPackOrExit(args, 'fanout', useJson);
133
147
 
134
148
  // FIX 4 (#61 whole-branch review, cheap parity): handleStart validates
135
149
  // --gateway via validateStartArgs (cli.js) — fanout never did, so a typo'd
@@ -140,9 +154,22 @@ async function handleFanout(args) {
140
154
  }
141
155
 
142
156
  const { resolvePromptSource } = require('./utils/prompt-source');
143
- const promptRes = resolvePromptSource(args);
144
- if (promptRes.error) {
145
- process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }));
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)' }));
146
173
  }
147
174
  // Council preset: expand a saved council into args.models (mutually exclusive with --models).
148
175
  const hasModels = typeof args.models === 'string' && args.models.trim();
@@ -190,7 +217,7 @@ async function handleFanout(args) {
190
217
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models must contain at least one non-empty entry' }));
191
218
  }
192
219
 
193
- // Direct require — the src/index.js public re-export is added later (Task 13)
220
+ // Direct require (fanout stays internal no src/index.js public re-export).
194
221
  const { runFanout } = require('./sidecar/fanout');
195
222
  const { loadConfig, resolveGatewayMode } = require('./utils/config');
196
223
  const { resolveFallbackConfig } = require('./sidecar/fallback-chains');
@@ -237,6 +264,7 @@ async function handleFanout(args) {
237
264
  config: cfg,
238
265
  }),
239
266
  catalog: (readCache() || {}).models || [],
267
+ pack: packRecord, // v4.5 Task 13: null when no --pack; additive on wave metadata.json + wave.json.
240
268
  });
241
269
  return exitCode;
242
270
  }
@@ -0,0 +1,53 @@
1
+ // src/cli-handlers-template.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module cli-handlers-template
6
+ * F9 (v4.5): `amicus template list|show`. No save/rm — templates are a folder
7
+ * of Markdown files; your editor is the manager.
8
+ */
9
+
10
+ const { SCHEMA_VERSION } = require('./utils/result-schema');
11
+ const { ERROR_CODES, failJson } = require('./utils/error-doc');
12
+ const { listTemplates, resolveTemplate, templatesDir } = require('./template/store');
13
+
14
+ async function handleTemplate(args) {
15
+ const useJson = !!args.json;
16
+ const sub = args._[1];
17
+
18
+ if (sub === 'list') {
19
+ const templates = listTemplates();
20
+ if (useJson) {
21
+ process.stdout.write(JSON.stringify({ schemaVersion: SCHEMA_VERSION, type: 'template-list', dir: templatesDir(), templates }, null, 2) + '\n');
22
+ return 0;
23
+ }
24
+ if (templates.length === 0) { process.stdout.write('No templates.\n'); return 0; }
25
+ for (const t of templates) {
26
+ const marker = t.builtin ? ' [built-in]' : (t.shadowed ? ' [shadows built-in]' : '');
27
+ process.stdout.write(`${t.name}${marker}\n`);
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ if (sub === 'show') {
33
+ const ref = args._[2];
34
+ if (!ref) {
35
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: template show requires a <name|path>' });
36
+ }
37
+ const t = resolveTemplate(ref);
38
+ if (t.error) {
39
+ return failJson(useJson, { code: ERROR_CODES.TEMPLATE_NOT_FOUND, message: t.error, hint: 'amicus template list' });
40
+ }
41
+ if (useJson) {
42
+ process.stdout.write(JSON.stringify({ schemaVersion: SCHEMA_VERSION, type: 'template', name: t.name, path: t.path, hash: t.hash, builtin: t.builtin, text: t.text }, null, 2) + '\n');
43
+ return 0;
44
+ }
45
+ process.stdout.write(t.text + (t.text.endsWith('\n') ? '' : '\n'));
46
+ return 0;
47
+ }
48
+
49
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
50
+ message: `Error: unknown template subcommand '${sub || ''}'`, hint: 'amicus template list | amicus template show <name|path>' });
51
+ }
52
+
53
+ module.exports = { handleTemplate };
package/src/cli.js CHANGED
@@ -44,6 +44,7 @@ function parseArgs(argv) {
44
44
  _: [],
45
45
  ...DEFAULTS
46
46
  };
47
+ result.__explicit = new Set();
47
48
 
48
49
  for (let i = 0; i < argv.length; i++) {
49
50
  const arg = argv[i];
@@ -63,19 +64,22 @@ function parseArgs(argv) {
63
64
  // Boolean flags (no value expected)
64
65
  if (isBooleanFlag(key)) {
65
66
  result[key] = true;
67
+ result.__explicit.add(key);
66
68
  continue;
67
69
  }
68
70
 
69
71
  // If --key=value was used, use the inline value directly
70
72
  if (inlineValue !== undefined) {
71
73
  result[key] = parseValue(key, inlineValue);
74
+ result.__explicit.add(key);
72
75
  continue;
73
76
  }
74
77
 
75
78
  // Array accumulation flags
76
- if (key === 'exclude-mcp' && next && !next.startsWith('--')) {
77
- result['exclude-mcp'] = result['exclude-mcp'] || [];
78
- result['exclude-mcp'].push(next);
79
+ if ((key === 'exclude-mcp' || key === 'var') && next && !next.startsWith('--')) {
80
+ result[key] = result[key] || [];
81
+ result[key].push(next);
82
+ result.__explicit.add(key);
79
83
  i++;
80
84
  continue;
81
85
  }
@@ -85,6 +89,7 @@ function parseArgs(argv) {
85
89
  // boolean so it can never swallow the following positional as a value.
86
90
  if (key.startsWith('no-')) {
87
91
  result[key] = true;
92
+ result.__explicit.add(key);
88
93
  continue;
89
94
  }
90
95
 
@@ -95,6 +100,7 @@ function parseArgs(argv) {
95
100
  } else {
96
101
  result[key] = true;
97
102
  }
103
+ result.__explicit.add(key);
98
104
  } else if (arg === '-o') {
99
105
  // Single short-flag alias, scoped to exactly '-o' (council verdict's
100
106
  // --out shorthand). No general short-flag support is implemented —
@@ -106,6 +112,7 @@ function parseArgs(argv) {
106
112
  } else {
107
113
  result.out = true;
108
114
  }
115
+ result.__explicit.add('out');
109
116
  } else {
110
117
  result._.push(arg);
111
118
  }
@@ -428,6 +435,10 @@ Options for 'start':
428
435
  --no-validate-model Skip model-catalog validation before launch
429
436
  --gateway <mode> Routing: auto (direct-first), direct, or openrouter
430
437
  --position <pos> Window position: right (default), left, center
438
+ --template <name|path> Render a briefing template ({{prompt}}, {{artifact}}, {{var.*}})
439
+ --artifact <file> File whose content fills {{artifact}} (256 KB cap; needs --template)
440
+ --var <k=v> Template variable, repeatable (needs --template)
441
+ --pack <name|path> Load a saved pack (model/options/template); explicit flags override it
431
442
  `,
432
443
  fanout: `
433
444
  Options for 'fanout':
@@ -459,6 +470,10 @@ Options for 'fanout':
459
470
  RESULT_FILE/EVENTS_FILE/COST/PROJECT), never model
460
471
  text. Child stdout/stderr go to amicus stderr.
461
472
  Never changes the wave's exit code, docs, or events.
473
+ --template <name|path> Render a briefing template ({{prompt}}, {{artifact}}, {{var.*}})
474
+ --artifact <file> File whose content fills {{artifact}} (256 KB cap; needs --template)
475
+ --var <k=v> Template variable, repeatable (needs --template)
476
+ --pack <name|path> Load a saved pack (bench/options/template); explicit flags override it
462
477
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
463
478
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
464
479
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -545,6 +560,8 @@ Subcommands for 'council':
545
560
  [--gateway auto|direct|openrouter] [--no-validate-model]
546
561
  [--debate] [--claude-review <file>] [--no-cost-gate] [--follow]
547
562
  [--fallback] [--no-fallback] [--on-complete <cmd>]
563
+ [--template <name|path>] [--artifact <file>] [--var <k=v>]
564
+ [--pack <name|path>]
548
565
  Run the full headless council engine (v4.0).
549
566
  Chair default: deepseek (must NOT be a bench seat).
550
567
  --critic and --lenses are mutually exclusive.
@@ -567,6 +584,13 @@ Subcommands for 'council':
567
584
  EVENTS_FILE/COST/PROJECT), never model text. Child
568
585
  stdout/stderr go to amicus stderr. Never changes
569
586
  the run's exit code, docs, or events.
587
+ --template <name|path> renders a briefing from
588
+ {{prompt}}, {{artifact}}, {{var.*}}; --artifact
589
+ fills {{artifact}} (256 KB cap); --var sets
590
+ {{var.*}} (repeatable). Both require --template.
591
+ --pack <name|path> loads a saved pack (bench,
592
+ chair, critic/lenses, options, template);
593
+ explicit flags always override the pack's values.
570
594
  Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
571
595
  save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
572
596
  --json Machine-readable output
@@ -639,6 +663,43 @@ Options for 'init':
639
663
  Runs skill install + MCP registration on demand (for plugin-channel /
640
664
  --ignore-scripts installs, a failed postinstall, or repairing deleted
641
665
  ~/.claude state). No flags registers both Claude Code and Claude Desktop.
666
+ `,
667
+ template: `
668
+ Options for 'template':
669
+ amicus template list [--json] List templates (built-ins marked)
670
+ amicus template show <name|path> [--json] Print a template
671
+ `,
672
+ pack: `
673
+ Options for 'pack':
674
+ amicus pack save <name> --kind council|fanout|solo [flags]
675
+ Save a pack built from flags:
676
+ --bench <a,b,c|name> council/fanout: comma-
677
+ separated members, or a
678
+ saved council name
679
+ --model <model> solo kind only
680
+ --chair/--critic/--lenses council kind only
681
+ --timeout/--max-cost/--gateway shared run
682
+ options
683
+ --agent/--thinking/--summary-length fanout/
684
+ solo kind only
685
+ --debate / --no-debate council kind only
686
+ --template <name|path> briefing template
687
+ reference (not rendered)
688
+ --version <semver> default 1.0.0 (an
689
+ unchanged re-save is a
690
+ no-op; a changed one
691
+ auto-bumps the patch)
692
+ --description <text>
693
+ amicus pack save <name> --from-run <id>
694
+ Build a pack from an existing council run /
695
+ fanout wave / solo session instead of flags
696
+ (models, options, and a template REFERENCE only
697
+ — briefing text is never captured)
698
+ amicus pack list [--json] List saved packs
699
+ amicus pack show <name|path> [--json]
700
+ Print a pack plus its validation report (never
701
+ fails on an invalid pack — see 'validation')
702
+ amicus pack rm <name> [--json] Remove a saved pack
642
703
  `
643
704
  };
644
705