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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +130 -0
- package/README.md +15 -2
- package/bin/amicus.js +10 -0
- package/docs/ROADMAP.md +36 -10
- package/docs/configuration.md +24 -0
- package/docs/council.md +59 -0
- package/docs/schemas.md +1 -0
- package/docs/usage.md +151 -1
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-panels.js +76 -18
- package/electron/workspace-ui/workspace-render.js +10 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +14 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +1 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-template.js +53 -0
- package/src/cli.js +64 -3
- package/src/council/findings.js +4 -41
- package/src/council/presets-cli.js +23 -11
- package/src/council/run-stages.js +12 -9
- package/src/council/run-state.js +17 -0
- package/src/council/run.js +1 -1
- package/src/headless.js +18 -14
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/fanout.js +21 -4
- package/src/sidecar/progress.js +34 -0
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/workspace/artifact-guard.js +44 -6
- package/src/workspace/run-detail.js +6 -0
package/src/sidecar/start.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/utils/config.js
CHANGED
|
@@ -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
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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
|
|
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
|
};
|
package/src/utils/error-doc.js
CHANGED
|
@@ -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
|
|