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
@@ -35,7 +35,7 @@ function generateTaskId() {
35
35
 
36
36
  /** Create session directory and save metadata */
37
37
  function createSessionMetadata(taskId, project, options) {
38
- const { model, prompt, briefing, noUi, headless, agent, thinking } = options;
38
+ const { model, prompt, briefing, noUi, headless, agent, thinking, pack } = options;
39
39
 
40
40
  const sessionDir = SessionPaths.sessionDir(project, taskId);
41
41
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
@@ -65,7 +65,8 @@ function createSessionMetadata(taskId, project, options) {
65
65
  thinking: thinking || 'medium',
66
66
  status: 'running',
67
67
  pid: existing.pid || process.pid,
68
- createdAt: existing.createdAt || new Date().toISOString()
68
+ createdAt: existing.createdAt || new Date().toISOString(),
69
+ ...(pack ? { pack } : {}), // v4.5 Task 13: absent-not-null; ...existing above preserves a prior write when this call omits pack.
69
70
  };
70
71
 
71
72
  writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
@@ -153,7 +154,7 @@ async function startSidecar(options) {
153
154
  contextMaxTokens = 80000, noUi, headless = false, timeout = 15,
154
155
  agent, mcp, mcpConfig, summaryLength = 'normal', thinking,
155
156
  client, sessionDir, noMcp, excludeMcp, opencodePort, coworkProcess, includeContext = true,
156
- position = 'right', json = false, modelInput = null
157
+ position = 'right', json = false, modelInput = null, pack = null
157
158
  } = options;
158
159
 
159
160
  const effectivePrompt = prompt || briefing;
@@ -182,7 +183,7 @@ async function startSidecar(options) {
182
183
  );
183
184
 
184
185
  const sessDir = createSessionMetadata(taskId, effectiveProject, {
185
- model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking
186
+ model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking, pack
186
187
  });
187
188
  saveInitialContext(sessDir, systemPrompt, userMessage);
188
189
  acquireLock(sessDir, effectiveHeadless ? 'headless' : 'interactive');
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Workspace Auto-Open Decision Helper
3
+ *
4
+ * Pure helper to determine whether the Council Workspace should auto-open
5
+ * on MCP council runs from Claude Code (local). Returns the decision and reason.
6
+ *
7
+ * Decision order (spec §6 guard 4):
8
+ * 1. uiParam === false → 'param-suppressed' (explicit user request beats everything, checked first)
9
+ * 2. Hard guards (always checked, beat even explicit true):
10
+ * - !electronUsable → 'electron-absent'
11
+ * - platform === 'linux' && !env.DISPLAY → 'no-display'
12
+ * 3. uiParam === true → 'ok' (explicit request overrides config and client gate, never hard guards)
13
+ * 4. autoOpenConfig === false → 'config-disabled'
14
+ * 5. client !== 'code-local' → 'client-not-code-local'
15
+ * 6. else → 'ok'
16
+ *
17
+ * @param {object} options
18
+ * @param {string} options.client - The client type (e.g., 'code-local', 'cowork', 'code-web')
19
+ * @param {boolean} options.electronUsable - Whether Electron is available
20
+ * @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
21
+ * @param {object} options.env - Environment variables object
22
+ * @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
23
+ * @param {boolean|undefined} options.uiParam - Explicit UI parameter (true, false, or undefined)
24
+ * @returns {{open: boolean, reason: string}}
25
+ */
26
+ function shouldAutoOpenWorkspace({
27
+ client,
28
+ electronUsable,
29
+ platform,
30
+ env,
31
+ autoOpenConfig,
32
+ uiParam,
33
+ }) {
34
+ // Step 1: uiParam === false beats everything (checked first)
35
+ if (uiParam === false) {
36
+ return { open: false, reason: 'param-suppressed' };
37
+ }
38
+
39
+ // Step 2: Hard guards (always checked, beat even explicit uiParam === true)
40
+ if (!electronUsable) {
41
+ return { open: false, reason: 'electron-absent' };
42
+ }
43
+
44
+ if (platform === 'linux' && !env.DISPLAY) {
45
+ return { open: false, reason: 'no-display' };
46
+ }
47
+
48
+ // Step 3: uiParam === true overrides config and client gate (but not hard guards)
49
+ if (uiParam === true) {
50
+ return { open: true, reason: 'ok' };
51
+ }
52
+
53
+ // Step 4: autoOpenConfig === false
54
+ if (autoOpenConfig === false) {
55
+ return { open: false, reason: 'config-disabled' };
56
+ }
57
+
58
+ // Step 5: client !== 'code-local'
59
+ if (client !== 'code-local') {
60
+ return { open: false, reason: 'client-not-code-local' };
61
+ }
62
+
63
+ // Step 6: else
64
+ return { open: true, reason: 'ok' };
65
+ }
66
+
67
+ module.exports = {
68
+ shouldAutoOpenWorkspace,
69
+ };
@@ -59,4 +59,49 @@ async function launchWorkspaceWindow({ project, runId = '' }, deps = {}) {
59
59
  });
60
60
  }
61
61
 
62
- module.exports = { launchWorkspaceWindow };
62
+ /**
63
+ * v4.5 auto-open: fire-and-forget Workspace launch for the MCP path.
64
+ * DELIBERATELY different from launchWorkspaceWindow above:
65
+ * - never provisions (isElectronUsable check only — ensureElectron can
66
+ * DOWNLOAD Electron, which auto-open guard 3 forbids);
67
+ * - no stdout relay (the caller may be an MCP stdio server whose stdout IS
68
+ * the JSON-RPC channel — relaying would corrupt the protocol);
69
+ * - detached + unref, returns immediately (the sibling's promise resolves
70
+ * only when the window CLOSES, which no request path may wait on).
71
+ * @param {{project: string, runId?: string}} opts
72
+ * @param {{isElectronUsable?: Function, resolveElectronBinary?: Function, spawn?: Function}} [deps]
73
+ * @returns {{launched: boolean, reason?: string}}
74
+ */
75
+ function launchWorkspaceWindowDetached({ project, runId = '' }, deps = {}) {
76
+ const usable = deps.isElectronUsable || require('./electron-install').isElectronUsable;
77
+ const resolveExe = deps.resolveElectronBinary || require('./electron-install').resolveElectronBinary;
78
+ const spawnFn = deps.spawn || spawn;
79
+ if (!usable()) { return { launched: false, reason: 'electron-absent' }; }
80
+ const electronPath = resolveExe() || getElectronPath();
81
+ const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
82
+ const env = {
83
+ ...process.env,
84
+ AMICUS_MODE: 'council-workspace',
85
+ AMICUS_PROJECT: project,
86
+ AMICUS_RUN_ID: runId || '',
87
+ AMICUS_FOLD_NONCE: generateFoldNonce(),
88
+ };
89
+ try {
90
+ const proc = spawnFn(electronPath, [mainPath], { env, detached: true, stdio: 'ignore' });
91
+ // Node emits spawn failures (ENOENT/EACCES/corrupt binary) as an async
92
+ // 'error' event on the child; an unlistened ChildProcess 'error' is an
93
+ // uncaught exception. The MCP server (`amicus mcp`) installs no
94
+ // uncaughtException handler (bin/amicus.js only does that for
95
+ // start/continue), so that would kill the JSON-RPC channel. Best-effort:
96
+ // just log it, matching the fire-and-forget contract of this function.
97
+ proc.on('error', (err) => logger.debug('Workspace auto-open child failed (best-effort)', { error: err.message }));
98
+ proc.unref();
99
+ logger.info('Auto-opened council workspace (detached)', { runId: runId || '(run list)' });
100
+ return { launched: true };
101
+ } catch (err) {
102
+ logger.debug('Workspace auto-open spawn failed (best-effort)', { error: err.message });
103
+ return { launched: false, reason: `spawn-failed: ${err.message}` };
104
+ }
105
+ }
106
+
107
+ module.exports = { launchWorkspaceWindow, launchWorkspaceWindowDetached };
@@ -0,0 +1,88 @@
1
+ // src/template/apply.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module template/apply
6
+ * F9 (v4.5): the one seam that turns (--template, --prompt, --artifact, --var)
7
+ * into a rendered briefing + template-sourced promptMeta. Used by the three CLI
8
+ * run commands and by pack-resolve for a pack's briefing.template (which is how
9
+ * templates reach MCP callers — MCP has no template params of its own).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { ERROR_CODES } = require('../utils/error-doc');
15
+ const { resolveTemplate } = require('./store');
16
+ const { renderTemplate } = require('./render');
17
+
18
+ const ARTIFACT_CAP_BYTES = 256 * 1024;
19
+
20
+ /**
21
+ * @param {{templateRef: string, prompt?: string, artifactFile?: string,
22
+ * varList?: string[], project: string}} opts
23
+ * @returns {{prompt, promptMeta, notices} | {error: {code, message, hint}}}
24
+ */
25
+ function applyTemplate({ templateRef, prompt, artifactFile, varList, project }) {
26
+ const tpl = resolveTemplate(templateRef);
27
+ if (tpl.error) {
28
+ return { error: { code: ERROR_CODES.TEMPLATE_NOT_FOUND, message: tpl.error, hint: 'amicus template list' } };
29
+ }
30
+
31
+ const vars = {};
32
+ // F4 (Task-5 review): parseArgs' inline `--var=k=v` form takes the single-value
33
+ // branch, not the array-accumulation one, so varList arrives as a bare string
34
+ // instead of a one-element array — wrap it rather than let `for..of` iterate
35
+ // its characters. parseArgs itself is unchanged (plan-mandated, shared with
36
+ // --exclude-mcp); this coercion is the seam that absorbs both shapes.
37
+ const varArr = Array.isArray(varList)
38
+ ? varList
39
+ : (varList !== undefined && varList !== null) ? [varList] : [];
40
+ for (const entry of varArr) {
41
+ const eq = String(entry).indexOf('=');
42
+ if (eq < 1) {
43
+ return { error: { code: ERROR_CODES.BAD_ARGS, message: `Error: --var expects key=value, got '${entry}'`, hint: null } };
44
+ }
45
+ vars[String(entry).slice(0, eq)] = String(entry).slice(eq + 1);
46
+ }
47
+
48
+ let artifact; let artifactPath;
49
+ if (artifactFile !== undefined) {
50
+ artifactPath = path.resolve(String(artifactFile));
51
+ let raw;
52
+ try {
53
+ raw = fs.readFileSync(artifactPath);
54
+ } catch (err) {
55
+ return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: cannot read --artifact ${artifactFile}: ${err.message}`, hint: null } };
56
+ }
57
+ if (raw.length > ARTIFACT_CAP_BYTES) {
58
+ return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: `Error: --artifact ${artifactFile} is ${raw.length} bytes; the cap is 256 KB`, hint: null } };
59
+ }
60
+ artifact = raw.toString('utf-8');
61
+ if (artifact.charCodeAt(0) === 0xFEFF) { artifact = artifact.slice(1); }
62
+ }
63
+
64
+ const res = renderTemplate(tpl.text, {
65
+ prompt,
66
+ artifact,
67
+ artifactPath,
68
+ date: new Date().toISOString().slice(0, 10),
69
+ project: String(project),
70
+ vars,
71
+ });
72
+ if (res.error) {
73
+ return { error: { code: ERROR_CODES.TEMPLATE_RENDER, message: res.error, hint: null } };
74
+ }
75
+
76
+ return {
77
+ prompt: res.text,
78
+ promptMeta: {
79
+ source: 'template',
80
+ file: tpl.path,
81
+ chars: res.text.length,
82
+ template: { name: tpl.name, hash: tpl.hash },
83
+ },
84
+ notices: res.notices,
85
+ };
86
+ }
87
+
88
+ module.exports = { applyTemplate, ARTIFACT_CAP_BYTES };
@@ -0,0 +1,86 @@
1
+ // src/template/render.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module template/render
6
+ * F9 (v4.5): strict {{variable}} rendering for briefing templates. Expansion
7
+ * happens ONLY in template files (spec carried decision 7) — --prompt text is
8
+ * always literal, so this module never sees non-template input.
9
+ *
10
+ * v4.5 variable set. {{input}} is deliberately ABSENT — it ships with v4.6's
11
+ * --input-from; on v4.5 it fails as an unknown variable, which is accurate.
12
+ * No {{model}}: prompts are built once per wave, model-independent.
13
+ */
14
+
15
+ const VAR_RE = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
16
+ const KNOWN_VARIABLES = ['prompt', 'artifact', 'artifact_path', 'date', 'project', 'var.<key>'];
17
+
18
+ function knownList() {
19
+ return KNOWN_VARIABLES.map((v) => `{{${v}}}`).join(', ');
20
+ }
21
+
22
+ /**
23
+ * Render a template with strict typo-safety rules:
24
+ * unknown variable -> error; slot present without its data -> error; data
25
+ * passed without its slot -> error ("silently dropped"); unused --var -> notice.
26
+ *
27
+ * @param {string} text - raw template text
28
+ * @param {{prompt?: string, artifact?: string, artifactPath?: string,
29
+ * date: string, project: string, vars?: Object<string,string>}} data
30
+ * @returns {{text: string, notices: string[]} | {error: string}}
31
+ */
32
+ function renderTemplate(text, data) {
33
+ const vars = data.vars || {};
34
+ const used = new Set();
35
+ for (const m of String(text).matchAll(VAR_RE)) { used.add(m[1]); }
36
+
37
+ for (const name of used) {
38
+ if (name.startsWith('var.')) {
39
+ const key = name.slice(4);
40
+ if (!(key in vars)) {
41
+ return { error: `Error: template uses {{var.${key}}} but no --var ${key}=<value> was given` };
42
+ }
43
+ continue;
44
+ }
45
+ if (!['prompt', 'artifact', 'artifact_path', 'date', 'project'].includes(name)) {
46
+ return { error: `Error: Unknown template variable {{${name}}}. Known: ${knownList()}` };
47
+ }
48
+ }
49
+
50
+ if (used.has('prompt') && data.prompt === undefined) {
51
+ return { error: 'Error: template has {{prompt}} but no --prompt/--prompt-file was given' };
52
+ }
53
+ if (!used.has('prompt') && data.prompt !== undefined) {
54
+ return { error: 'Error: --prompt/--prompt-file was given but the template has no {{prompt}} slot — the text would be silently dropped' };
55
+ }
56
+ const usesArtifact = used.has('artifact') || used.has('artifact_path');
57
+ if (used.has('artifact') && data.artifact === undefined) {
58
+ return { error: 'Error: template has {{artifact}} but no --artifact <file> was given' };
59
+ }
60
+ if (used.has('artifact_path') && data.artifactPath === undefined) {
61
+ return { error: 'Error: template has {{artifact_path}} but no --artifact <file> was given' };
62
+ }
63
+ if (!usesArtifact && (data.artifact !== undefined || data.artifactPath !== undefined)) {
64
+ return { error: 'Error: --artifact was given but the template has no {{artifact}}/{{artifact_path}} slot — the file would be silently dropped' };
65
+ }
66
+
67
+ const notices = [];
68
+ for (const key of Object.keys(vars)) {
69
+ if (!used.has(`var.${key}`)) {
70
+ notices.push(`Notice: --var ${key}=… is not used by this template`);
71
+ }
72
+ }
73
+
74
+ const rendered = String(text).replace(VAR_RE, (_, name) => {
75
+ if (name === 'prompt') { return data.prompt; }
76
+ if (name === 'artifact') { return data.artifact; }
77
+ if (name === 'artifact_path') { return data.artifactPath; }
78
+ if (name === 'date') { return data.date; }
79
+ if (name === 'project') { return data.project; }
80
+ return vars[name.slice(4)];
81
+ });
82
+
83
+ return { text: rendered, notices };
84
+ }
85
+
86
+ module.exports = { renderTemplate, KNOWN_VARIABLES };
@@ -0,0 +1,106 @@
1
+ // src/template/store.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module template/store
6
+ * F9 (v4.5): briefing templates are Markdown files in <configDir>/templates/
7
+ * (peer of packs/). Name = basename sans .md. Built-ins are embedded strings,
8
+ * shadowed by a same-named user file — exactly the built-in-bench precedent
9
+ * (config.js getCouncilWithSource). No save/rm: your editor is the manager.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+
16
+ // Lazy so jest.doMock / AMICUS_CONFIG_DIR re-pointing works per-test.
17
+ function _getConfigDir() { return require('../utils/config').getConfigDir(); }
18
+
19
+ /**
20
+ * v4.5 ships `review` only. `critique`/`refine` are {{input}}-centric and
21
+ * arrive with v4.6's chaining (--input-from).
22
+ */
23
+ const BUILTIN_TEMPLATES = Object.freeze({
24
+ review: [
25
+ '# Review briefing',
26
+ '',
27
+ 'You are reviewing the artifact below against the caller\'s focus.',
28
+ '',
29
+ '## Focus',
30
+ '',
31
+ '{{prompt}}',
32
+ '',
33
+ '## Artifact ({{artifact_path}})',
34
+ '',
35
+ '{{artifact}}',
36
+ '',
37
+ '## Instructions',
38
+ '',
39
+ '- Ground every finding in the artifact text and cite its location.',
40
+ '- Give each finding a severity: critical / major / minor / nit.',
41
+ '- If you find nothing at a severity, say so explicitly.',
42
+ '- End with a one-paragraph overall verdict.',
43
+ '',
44
+ ].join('\n'),
45
+ });
46
+
47
+ /** @returns {string} the user templates directory (peer of packs/) */
48
+ function templatesDir() {
49
+ return path.join(_getConfigDir(), 'templates');
50
+ }
51
+
52
+ function hashText(text) {
53
+ return crypto.createHash('sha256').update(text, 'utf-8').digest('hex').slice(0, 12);
54
+ }
55
+
56
+ function stripBom(text) {
57
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
58
+ }
59
+
60
+ /**
61
+ * @param {string} nameOrPath - a path when it contains a path separator or
62
+ * ends in `.md`; otherwise a template name.
63
+ * @returns {{name, path: string|null, text, hash, builtin: boolean} | {error: string}}
64
+ */
65
+ function resolveTemplate(nameOrPath) {
66
+ const v = String(nameOrPath);
67
+ const isPath = v.endsWith('.md') || v.includes('/') || v.includes(path.sep);
68
+ if (isPath) {
69
+ const abs = path.resolve(v);
70
+ let text;
71
+ try {
72
+ text = stripBom(fs.readFileSync(abs, 'utf-8'));
73
+ } catch (err) {
74
+ return { error: `Error: cannot read template ${v}: ${err.message}` };
75
+ }
76
+ return { name: path.basename(abs, '.md'), path: abs, text, hash: hashText(text), builtin: false };
77
+ }
78
+ const userFile = path.join(templatesDir(), `${v}.md`);
79
+ try {
80
+ const text = stripBom(fs.readFileSync(userFile, 'utf-8'));
81
+ return { name: v, path: userFile, text, hash: hashText(text), builtin: false };
82
+ } catch { /* fall through to built-ins */ }
83
+ if (Object.prototype.hasOwnProperty.call(BUILTIN_TEMPLATES, v)) {
84
+ const text = BUILTIN_TEMPLATES[v];
85
+ return { name: v, path: null, text, hash: hashText(text), builtin: true };
86
+ }
87
+ return { error: `Error: Template '${v}' not found (looked in ${templatesDir()} and built-ins)` };
88
+ }
89
+
90
+ /** @returns {Array<{name, builtin: boolean, shadowed: boolean}>} name-sorted */
91
+ function listTemplates() {
92
+ const out = new Map();
93
+ for (const name of Object.keys(BUILTIN_TEMPLATES)) {
94
+ out.set(name, { name, builtin: true, shadowed: false });
95
+ }
96
+ let entries = [];
97
+ try { entries = fs.readdirSync(templatesDir()); } catch { /* no user dir yet */ }
98
+ for (const f of entries) {
99
+ if (!f.endsWith('.md')) { continue; }
100
+ const name = path.basename(f, '.md');
101
+ out.set(name, { name, builtin: false, shadowed: Object.prototype.hasOwnProperty.call(BUILTIN_TEMPLATES, name) });
102
+ }
103
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
104
+ }
105
+
106
+ module.exports = { templatesDir, resolveTemplate, listTemplates, BUILTIN_TEMPLATES };
@@ -401,14 +401,57 @@ function getCouncilWithSource(name, catalog = []) {
401
401
  return { members: null, builtin: false };
402
402
  }
403
403
 
404
+ /**
405
+ * Per-member alias/catalog classification — the SAME check `resolveCouncilMembers`
406
+ * (below) uses to decide the real run path's bench, extracted so `amicus council
407
+ * show` (council/presets-cli.js) can reuse it verbatim instead of re-deriving a
408
+ * parallel (and, pre-v4.5-Wave-2, drifted) check. Each member is resolved to its
409
+ * full model id (alias → id via effective aliases; a member containing '/' is
410
+ * taken as-is) and that id checked against the cached catalog. Tri-state catalog
411
+ * rule: an EMPTY catalog (offline / never fetched) never drops anything —
412
+ * "unknown" is not "delisted" — and a local-vendor member is never dropped on
413
+ * catalog absence either way (v4.2 §4.4: a local server may simply have been off
414
+ * at the last refresh; the leg itself fails pre-flight with the actionable
415
+ * local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
416
+ * catalog that omits the resolved id is a definitive drop.
417
+ * @param {string[]} members raw council members (aliases or provider/model ids)
418
+ * @param {Array<{id:string}>} [catalog]
419
+ * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
420
+ * `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
421
+ * callers keep working); `droppedMembers` additively pairs each with WHY.
422
+ */
423
+ function classifyCouncilMembers(members, catalog = []) {
424
+ const aliases = getEffectiveAliases();
425
+ const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
426
+ const { isLocalProvider } = require('./local-providers');
427
+ const models = [];
428
+ const dropped = [];
429
+ const droppedMembers = [];
430
+ for (const member of members) {
431
+ const id = member.includes('/') ? member : aliases[member];
432
+ if (!id) { // alias no longer resolves
433
+ dropped.push(member);
434
+ droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
435
+ continue;
436
+ }
437
+ const vendor = typeof id === 'string' ? id.split('/')[0] : '';
438
+ if (isLocalProvider(vendor)) { models.push(member); continue; }
439
+ if (known.size > 0 && !known.has(id)) { // delisted model
440
+ dropped.push(member);
441
+ droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
442
+ continue;
443
+ }
444
+ models.push(member);
445
+ }
446
+ return { models, dropped, droppedMembers };
447
+ }
448
+
404
449
  /**
405
450
  * Expand a saved council into a runnable members list, degrading gracefully.
406
- * Each member is resolved to its full model id (alias id via effective
407
- * aliases; a member containing '/' is taken as-is) and that id checked against
408
- * the cached catalog. Unresolvable aliases and delisted ids are dropped with a
409
- * warning rather than fail-fast-aborting the whole wave. The catalog check is
410
- * skipped when the catalog is empty (offline). Returns members RAW (alias or
411
- * id) — leg-time validation resolves them again.
451
+ * Unresolvable aliases and delisted ids are dropped with a warning rather than
452
+ * fail-fast-aborting the whole wave (classification: classifyCouncilMembers
453
+ * above). Returns members RAW (alias or id) leg-time validation resolves
454
+ * them again.
412
455
  *
413
456
  * Resolution order: user config (`config.councils`) is checked first; when
414
457
  * `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
@@ -416,7 +459,7 @@ function getCouncilWithSource(name, catalog = []) {
416
459
  * shadows a built-in of the same name.
417
460
  * @param {string} name
418
461
  * @param {Array<{id:string}>} [catalog]
419
- * @returns {{models:string[], dropped:string[]} | {error:string}}
462
+ * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>} | {error:string}}
420
463
  */
421
464
  function resolveCouncilMembers(name, catalog = []) {
422
465
  const { members } = getCouncilWithSource(name, catalog);
@@ -426,23 +469,7 @@ function resolveCouncilMembers(name, catalog = []) {
426
469
  if (!Array.isArray(members) || members.length === 0) {
427
470
  return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
428
471
  }
429
- const aliases = getEffectiveAliases();
430
- const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
431
- const { isLocalProvider } = require('./local-providers');
432
- const models = [];
433
- const dropped = [];
434
- for (const member of members) {
435
- const id = member.includes('/') ? member : aliases[member];
436
- if (!id) { dropped.push(member); continue; } // alias no longer resolves
437
- const vendor = typeof id === 'string' ? id.split('/')[0] : '';
438
- // v4.2 §4.4: a local server may simply have been off at the last catalog
439
- // refresh — that is "unknown", not "delisted". Never drop a local-vendor
440
- // member on catalog absence; the leg itself fails pre-flight with the
441
- // actionable local_endpoint_unreachable error if the server is truly down.
442
- if (isLocalProvider(vendor)) { models.push(member); continue; }
443
- if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
444
- models.push(member);
445
- }
472
+ const { models, dropped, droppedMembers } = classifyCouncilMembers(members, catalog);
446
473
  if (models.length < 2) {
447
474
  return {
448
475
  error: `Council '${name}' has fewer than 2 usable members` +
@@ -450,7 +477,7 @@ function resolveCouncilMembers(name, catalog = []) {
450
477
  '. Run \'amicus setup\' to refresh it.',
451
478
  };
452
479
  }
453
- return { models, dropped };
480
+ return { models, dropped, droppedMembers };
454
481
  }
455
482
 
456
483
  /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
@@ -503,6 +530,17 @@ function hasTierOnboarded() {
503
530
  return !!(config.routing && config.routing.tier_onboarded === true);
504
531
  }
505
532
 
533
+ /**
534
+ * v4.5 auto-open (spec §6 guard 4): the Workspace auto-opens on MCP council
535
+ * runs from Claude Code (local) unless config.workspace.autoOpen === false. Only an
536
+ * explicit false disables — absent/junk values stay ON (opt-out semantics).
537
+ * @returns {boolean}
538
+ */
539
+ function getWorkspaceAutoOpen() {
540
+ const config = loadConfig() || {};
541
+ return !(config.workspace && config.workspace.autoOpen === false);
542
+ }
543
+
506
544
  /**
507
545
  * Persist the one-time onboarding-notice flag, preserving any other routing
508
546
  * keys (prefer, tier, migration_notified). Best-effort: swallows any
@@ -562,6 +600,7 @@ module.exports = {
562
600
  getCouncils,
563
601
  getCouncil,
564
602
  getCouncilWithSource,
603
+ classifyCouncilMembers,
565
604
  resolveCouncilMembers,
566
605
  getRoutingConfig,
567
606
  resolveGatewayMode,
@@ -571,4 +610,5 @@ module.exports = {
571
610
  setCostTier,
572
611
  hasTierOnboarded,
573
612
  markTierOnboarded,
613
+ getWorkspaceAutoOpen,
574
614
  };
@@ -24,6 +24,11 @@ const ERROR_CODES = Object.freeze({
24
24
  COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
25
25
  // council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
26
26
  COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
27
+ TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', // --template name/path unresolvable at run time (v4.5 F9)
28
+ TEMPLATE_RENDER: 'TEMPLATE_RENDER', // strict render rule violated: unknown var, slot/data mismatch (v4.5 F9)
29
+ PACK_NOT_FOUND: 'PACK_NOT_FOUND', // --pack name not in packs dir / path unreadable (v4.5 B7/F5)
30
+ PACK_INVALID: 'PACK_INVALID', // pack schema/structural/seat validation failure (v4.5 B7/F5)
31
+ PACK_KIND_MISMATCH: 'PACK_KIND_MISMATCH', // e.g. a council pack passed to fanout (v4.5 B7/F5)
27
32
  });
28
33
 
29
34
  /**
@@ -90,6 +90,7 @@ function buildWaveResultFromSession(project, waveId) {
90
90
  waveId,
91
91
  legs,
92
92
  promptMeta: meta.promptMeta || null,
93
+ ...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
93
94
  createdAt: meta.createdAt || null,
94
95
  completedAt: meta.completedAt || null,
95
96
  });
@@ -44,7 +44,9 @@ function durationBetween(createdAt, completedAt) {
44
44
  * @param {string|null} [opts.modelInput] - What the caller typed (alias), if known
45
45
  * @param {string|null} [opts.sessionDir]
46
46
  * @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
47
- * @returns {object} run document
47
+ * @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
48
+ * metadata.pack was recorded (solo session launched via --pack), sourced straight off
49
+ * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
48
50
  */
49
51
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
50
52
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -68,6 +70,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
68
70
  sessionDir,
69
71
  opencodeSessionId: metadata.opencodeSessionId || null,
70
72
  usage: usage !== null ? usage : (metadata.usage || null),
73
+ ...(metadata.pack ? { pack: metadata.pack } : {}),
71
74
  };
72
75
  }
73
76
 
@@ -122,9 +125,11 @@ function waveExitCode(waveStatus) {
122
125
  * @param {string|null} [opts.completedAt]
123
126
  * @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
124
127
  * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
128
+ * @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
129
+ * additive — present only when the wave was launched via --pack (absent, never null, otherwise).
125
130
  * @returns {object} wave document
126
131
  */
127
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
132
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
128
133
  const { sumWaveUsage } = require('./pricing');
129
134
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
130
135
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -151,6 +156,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
151
156
  durationMs,
152
157
  usage: sumWaveUsage(legs),
153
158
  notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
159
+ ...(pack ? { pack } : {}),
154
160
  };
155
161
  }
156
162