amicus 4.4.1 → 4.5.1

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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +154 -0
  3. package/README.md +15 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/ROADMAP.md +38 -14
  6. package/docs/configuration.md +24 -0
  7. package/docs/council.md +62 -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-doctor.js +10 -0
  27. package/src/cli-handlers-pack.js +238 -0
  28. package/src/cli-handlers-run.js +36 -8
  29. package/src/cli-handlers-template.js +53 -0
  30. package/src/cli.js +64 -3
  31. package/src/council/findings.js +4 -41
  32. package/src/council/presets-cli.js +23 -11
  33. package/src/council/run-stages.js +12 -9
  34. package/src/council/run-state.js +17 -0
  35. package/src/council/run.js +1 -1
  36. package/src/headless.js +18 -14
  37. package/src/mcp-council-run.js +110 -4
  38. package/src/mcp-server.js +203 -7
  39. package/src/mcp-tools.js +15 -5
  40. package/src/pack/pack-cli.js +38 -0
  41. package/src/pack/pack-forward.js +96 -0
  42. package/src/pack/pack-resolve.js +297 -0
  43. package/src/pack/pack-store.js +130 -0
  44. package/src/pack/pack-validate.js +113 -0
  45. package/src/sidecar/electron-state.js +61 -0
  46. package/src/sidecar/fanout.js +21 -4
  47. package/src/sidecar/progress.js +34 -0
  48. package/src/sidecar/start.js +5 -4
  49. package/src/sidecar/workspace-auto-open.js +83 -0
  50. package/src/sidecar/workspace-window.js +46 -1
  51. package/src/template/apply.js +88 -0
  52. package/src/template/render.js +86 -0
  53. package/src/template/store.js +106 -0
  54. package/src/utils/config.js +65 -25
  55. package/src/utils/doctor-electron-mcp-check.js +150 -0
  56. package/src/utils/error-doc.js +5 -0
  57. package/src/utils/result-schema-rebuild.js +1 -0
  58. package/src/utils/result-schema.js +8 -2
  59. package/src/workspace/artifact-guard.js +44 -6
  60. package/src/workspace/run-detail.js +6 -0
@@ -130,6 +130,39 @@ function writeProgress(sessionDir, stage, extra = {}) {
130
130
  writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
131
131
  }
132
132
 
133
+ /**
134
+ * FR-1 (v4.5): stamp a TERMINAL stage into progress.json on a failure path,
135
+ * preserving previously recorded usage. Extracted from headless.js's A3 outer
136
+ * catch so the early-return paths and the exception path can never drift.
137
+ *
138
+ * writeProgress REBUILDS progress.json (no merge) — a bare terminal write would
139
+ * delete whatever real spend the last flush recorded, trading a stale-stage bug
140
+ * for a cost under-report on exactly the legs that failed. So the prior usage is
141
+ * read back and re-attached; `extra` fields beyond usage are deliberately
142
+ * dropped (readProgress derives message counts from conversation.jsonl).
143
+ *
144
+ * Runs on error paths: must never throw and never mask the original error.
145
+ *
146
+ * @param {string} sessionDir
147
+ * @param {string} errorMessage - the failure being recorded (stage derives from it)
148
+ * @returns {boolean} true if the terminal record was written
149
+ */
150
+ function writeTerminalProgressSafe(sessionDir, errorMessage) {
151
+ try {
152
+ let priorUsage = null;
153
+ try {
154
+ const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
155
+ if (prior && prior.usage) { priorUsage = prior.usage; }
156
+ } catch { /* no readable prior record: write the terminal stage without usage */ }
157
+ const { resolveTerminalState } = require('./session-finalize');
158
+ const stage = resolveTerminalState({ error: errorMessage }).status;
159
+ writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
133
166
  /**
134
167
  * Read progress from a session's conversation.jsonl and progress.json files.
135
168
  *
@@ -242,6 +275,7 @@ function readProgress(sessionDir) {
242
275
  module.exports = {
243
276
  readProgress,
244
277
  writeProgress,
278
+ writeTerminalProgressSafe,
245
279
  extractLatest,
246
280
  computeLastActivity,
247
281
  STAGE_LABELS,
@@ -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,83 @@
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
+ * - electron package-missing → 'electron-absent'
11
+ * - electron binary-missing → 'electron-broken: …' (#76: package present but
12
+ * the exe never arrived — a repairable state the old boolean conflated
13
+ * with never-installed; the reason names the dir and the fix)
14
+ * - platform === 'linux' && !env.DISPLAY → 'no-display'
15
+ * 3. uiParam === true → 'ok' (explicit request overrides config and client gate, never hard guards)
16
+ * 4. autoOpenConfig === false → 'config-disabled'
17
+ * 5. client !== 'code-local' → 'client-not-code-local'
18
+ * 6. else → 'ok'
19
+ *
20
+ * @param {object} options
21
+ * @param {string} options.client - The client type (e.g., 'code-local', 'cowork', 'code-web')
22
+ * @param {boolean} [options.electronUsable] - Legacy boolean probe (used only when electronState is absent)
23
+ * @param {'ok'|'package-missing'|'binary-missing'} [options.electronState] - 3-state probe (#76); takes precedence
24
+ * @param {string|null} [options.electronDir] - Resolved electron dir, named in the electron-broken reason
25
+ * @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
26
+ * @param {object} options.env - Environment variables object
27
+ * @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
28
+ * @param {boolean|undefined} options.uiParam - Explicit UI parameter (true, false, or undefined)
29
+ * @returns {{open: boolean, reason: string}}
30
+ */
31
+ function shouldAutoOpenWorkspace({
32
+ client,
33
+ electronUsable,
34
+ electronState,
35
+ electronDir,
36
+ platform,
37
+ env,
38
+ autoOpenConfig,
39
+ uiParam,
40
+ }) {
41
+ // Step 1: uiParam === false beats everything (checked first)
42
+ if (uiParam === false) {
43
+ return { open: false, reason: 'param-suppressed' };
44
+ }
45
+
46
+ // Step 2: Hard guards (always checked, beat even explicit uiParam === true).
47
+ // electronState (3-state, #76) wins over the legacy electronUsable boolean,
48
+ // whose false collapses to package-missing (the old 'electron-absent').
49
+ const state = electronState || (electronUsable ? 'ok' : 'package-missing');
50
+ if (state === 'package-missing') {
51
+ return { open: false, reason: 'electron-absent' };
52
+ }
53
+ if (state === 'binary-missing') {
54
+ const where = electronDir ? ` under ${electronDir}` : '';
55
+ return { open: false, reason: `electron-broken: binary missing${where} — run \`amicus doctor --fix\`` };
56
+ }
57
+
58
+ if (platform === 'linux' && !env.DISPLAY) {
59
+ return { open: false, reason: 'no-display' };
60
+ }
61
+
62
+ // Step 3: uiParam === true overrides config and client gate (but not hard guards)
63
+ if (uiParam === true) {
64
+ return { open: true, reason: 'ok' };
65
+ }
66
+
67
+ // Step 4: autoOpenConfig === false
68
+ if (autoOpenConfig === false) {
69
+ return { open: false, reason: 'config-disabled' };
70
+ }
71
+
72
+ // Step 5: client !== 'code-local'
73
+ if (client !== 'code-local') {
74
+ return { open: false, reason: 'client-not-code-local' };
75
+ }
76
+
77
+ // Step 6: else
78
+ return { open: true, reason: 'ok' };
79
+ }
80
+
81
+ module.exports = {
82
+ shouldAutoOpenWorkspace,
83
+ };
@@ -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
  };